Merge "Revert "Migrate all emails to send async via asyncPostUpdate""
diff --git a/.bazelversion b/.bazelversion
index 47b322c..1545d96 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.4.1
+3.5.0
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 762fb43..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>>
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 2cad722..09741aa 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2812,7 +2812,8 @@
 +
 Enable (or disable) the `'$site_path'/logs/httpd_log` request log.
 If enabled, an NCSA combined log format request log file is written
-out by the internal HTTP daemon.
+out by the internal HTTP daemon. The httpd log format is documented
+link:logs.html#_httpd_log[here].
 +
 `log4j.appender` with the name `httpd_log` can be configured to overwrite
 programmatic configuration.
@@ -4869,6 +4870,21 @@
 +
 By default, 30s.
 
+[[sshd.gracefulStopTimeout]]sshd.gracefulStopTimeout::
++
+Set a graceful stop time. If set, Gerrit ensures that all open SSH
+sessions are preserved for a maximum period of time, before forcing the
+shutdown of the SSH daemon. During this period, no new requests
+will be accepted. This option is meant to be used in setups performing
+rolling restarts.
++
+Values should use common unit suffixes to express their setting:
++
+* s, sec, second, seconds
+* m, min, minute, minutes
++
+By default, 0 seconds (immediate shutdown).
+
 [[sshd.maxConnectionsPerUser]]sshd.maxConnectionsPerUser::
 +
 Maximum number of concurrent SSH sessions that a user account
@@ -4994,6 +5010,7 @@
 +
 Enable (or disable) the `'$site_path'/logs/sshd_log` request log.
 If enabled, a request log file is written out by the SSH daemon.
+The sshd log format is documented link:logs.html#_sshd_log[here].
 +
 `log4j.appender` with the name `sshd_log` can be configured to overwrite
 programmatic configuration.
diff --git a/Documentation/config-reverseproxy.txt b/Documentation/config-reverseproxy.txt
index eff777b..3a9bcc7 100644
--- a/Documentation/config-reverseproxy.txt
+++ b/Documentation/config-reverseproxy.txt
@@ -21,6 +21,13 @@
   	listenUrl = proxy-http://127.0.0.1:8081/r/
 ----
 
+== Reverse proxy and client IPs
+
+When behind a reverse proxy the http_log will log the IP of the reverse proxy
+as client.ip. To log the correct client IP you must provide the
+'X-Forwarded-For' header from the reverse proxy.
+See the nginx configuration example below.
+
 
 == Apache 2 Configuration
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 01cd494..ee2f4a1 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -90,12 +90,12 @@
 
 ```
 $ cat << EOF > ~/.bazelrc
-> build --define=ABSOLUTE_JAVABASE=<path-to-java-13>
-> build --javabase=@bazel_tools//tools/jdk:absolute_javabase
-> build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
-> build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
-> build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
-> EOF
+build --define=ABSOLUTE_JAVABASE=<path-to-java-13>
+build --javabase=@bazel_tools//tools/jdk:absolute_javabase
+build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
+build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+EOF
 ```
 
 Now, invoking Bazel with just `bazel build :release` would include
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 78b2c15..c5b3bfc 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -27,14 +27,33 @@
 leveraged to run tests at the Git protocol level.
 
 Gatling is written in Scala, but the abstraction provided by the Gatling DSL makes the scenarios
-implementation easy even without any Scala knowledge. The
-link:https://gitenterprise.me/2019/12/20/stress-your-gerrit-with-gatling/[Stress your Gerrit with Gatling,role=external,window=_blank]
-blog post has more introductory information.
+implementation easy even without any Scala knowledge. The online `End-to-end tests`
+link:https://www.gerritcodereview.com/presentations.html#list-of-presentations[presentation,role=external,window=_blank]
+links posted on the homepage have more introductory information.
+
+== IDE: IntelliJ
 
 Examples of scenarios can be found in the `e2e-tests` directory. The files in that directory should
 be formatted using the mainstream
 link:https://plugins.jetbrains.com/plugin/1347-scala[Scala plugin for IntelliJ,role=external,window=_blank].
 The latter is not mandatory but preferred for `sbt` and Scala IDE purposes in this project.
+So, Eclipse can also be used alongside as a development IDE; this is described below.
+
+=== Eclipse
+
+1. Install the link:http://scala-ide.org/docs/user/gettingstarted.html[Scala plugin for Eclipse,role=external,window=_blank].
+1. Run `sbt eclipse` from the `e2e-tests` root directory.
+1. Import the resulting `e2e-tests` eclipse file inside the Gerrit project, in Eclipse.
+1. You should see errors in Eclipse telling you there are missing packages.
+1. This is due to the sbt-eclipse plugin not properly linking the Gerrit Gatling e2e tests with
+   Gatling Git plugin.
+1. You then have to right-click on the root directory and choose the build path->link source option.
+1. Then you have to browse to `.sbt/1.0/staging`, find the folder where gatling-git is contained,
+   and choose that.
+1. That last step should link the gatling-git plugin to the project; e2e tests should not show
+   errors anymore.
+1. You may get errors in the gatling-git directory; these should not affect Gerrit Gatling
+   development and can be ignored.
 
 == How to build the tests
 
@@ -163,10 +182,11 @@
 Plugin or otherwise non-core scenarios may do so just as well. The core java package
 `com.google.gerrit.scenarios` from the example above has to be replaced with the one under which
 those scenario classes are. Such extending scenarios can also add extension-specific properties.
-Early examples of this can be found in the Gerrit
-`link:https://gerrit.googlesource.com/plugins/high-availability[high-availability,role=external,window=_blank]`
-and `link:https://gerrit.googlesource.com/plugins/multi-site[multi-site,role=external,window=_blank]`
-plugins test code.
+Examples of this can be found in these Gerrit plugins test code:
+
+* `link:https://gerrit.googlesource.com/plugins/high-availability[high-availability,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/multi-site[multi-site,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/rename-project[rename-project,role=external,window=_blank]`
 
 Further above, the `_PROJECT` keyword is prefixed with an underscore, which means that its value
 gets automatically generated by the scenario. Any property setting for it is therefore not
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 9596a55..742cf42 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -41,6 +41,37 @@
 Filters on a folder, they will be overwritten the next time you run
 `tools/eclipse/project.py`.
 
+=== Eclipse project on MacOS
+
+By default, bazel uses `/private/var/tmp` as the
+link:https://docs.bazel.build/versions/master/output_directories.html[outputRoot on MacOS].
+This means that the eclipse project will reference libraries stored under that directory.
+However, MacOS runs periodic cleanup task which deletes the content under `/private/var/tmp`
+which wasn't accessed or modified for some days, by default 3 days. This can lead to a broken
+Eclipse project as referenced libraries get deleted.
+
+There are two possibilities to mitigate this issue.
+
+==== Change the location of the bazel output directory
+On Linux, the output directory defaults to `$HOME/.cache/bazel` and the same can be configured
+on Mac too. Edit, or create, the `$HOME/.bazelrc` file and add the following line:
+----
+startup --output_user_root=/Users/johndoe/.cache/bazel
+----
+
+==== Increase the treshold for the cleanup of temporary files
+The default treshold for the cleanup can be overriden by creating a configuration file under
+`/etc/periodic.conf` and setting a larger value for the `daily_clean_tmps_days`.
+
+An example `/etc/periodic.conf` file:
+
+----
+# This file overrides the settings from /etc/defaults/periodic.conf
+daily_clean_tmps_days="45"                              # If not accessed for
+----
+
+For more details about the proposed workaround see link:https://superuser.com/a/187105[this post]
+
 === Eclipse project with custom plugins ===
 
 To add custom plugins to the eclipse project add them to `tools/bzl/plugins.bzl`
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index a773276..b4ae469 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
diff --git a/Documentation/images/inline-edit-create-change-project-screen-dialog.png b/Documentation/images/inline-edit-create-change-project-screen-dialog.png
deleted file mode 100644
index ea5daa9..0000000
--- a/Documentation/images/inline-edit-create-change-project-screen-dialog.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-create-change-project-screen.png b/Documentation/images/inline-edit-create-change-project-screen.png
deleted file mode 100644
index e9c7033..0000000
--- a/Documentation/images/inline-edit-create-change-project-screen.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-create-follow-up-change.png b/Documentation/images/inline-edit-create-follow-up-change.png
deleted file mode 100644
index 3e81eee..0000000
--- a/Documentation/images/inline-edit-create-follow-up-change.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-edit-in-diff-screen-patch-list.png b/Documentation/images/inline-edit-edit-in-diff-screen-patch-list.png
deleted file mode 100644
index bdbc59d..0000000
--- a/Documentation/images/inline-edit-edit-in-diff-screen-patch-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/inline-edit-edit-in-patch-list.png b/Documentation/images/inline-edit-edit-in-patch-list.png
deleted file mode 100644
index 9a31e02..0000000
--- a/Documentation/images/inline-edit-edit-in-patch-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 4fb977a..8f36ecc 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -69,6 +69,7 @@
 . link:cmd-index.html[Command Line Tools]
 . link:config-plugins.html#replication[Replication]
 . link:config-plugins.html[Plugins]
+. link:logs.html[Log Files]
 . link:metrics.html[Metrics]
 . link:config-reverseproxy.html[Reverse Proxy]
 . link:config-auto-site-initialization.html[Automatic Site Initialization on Startup]
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
new file mode 100644
index 0000000..6624366
--- /dev/null
+++ b/Documentation/logs.txt
@@ -0,0 +1,165 @@
+= Gerrit Code Review - Logs
+
+Gerrit writes log files in the `$site_path/logs/` folder tracking requests,
+background and plugin activity and errors. By default logs are written in
+link:config-gerrit.html#log.textLogging[text format], optionally in
+link:config-gerrit.html#log.jsonLogging[JSON format].
+By default log files are link:config-gerrit.html#log.compress[compressed]
+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.
+
+=== HTTPD Log
+
+The httpd log tracks HTTP requests processed by Gerrit's http daemon
+and is written to `$site_path/logs/httpd_log`. Enabled or disabled via the
+link:config-gerrit.html#httpd.requestLog[httpd.requestLog] option.
+
+Format is an enhanced
+link:https://httpd.apache.org/docs/2.4/logs.html#combined[NCSA combined log],
+if a log field is not present, a "-" is substituted:
+
+* `host`: The IP address of the HTTP client that made the HTTP resource request.
+  If you are using a reverse proxy it depends on the proxy configuration if the
+  proxy IP address or the client IP address is logged.
+* `[thread name]`: name of the Java thread executing the request.
+* `remote logname`:  the identifier used to
+  link: https://tools.ietf.org/html/rfc1413[identify the client making the HTTP request],
+  Gerrit always logs a dash `-`.
+* `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.
+* `request`: The request line from the client is given in double quotes.
+** the HTTP method used by the client.
+** the resource the client requested.
+** the protocol/version used by the client.
+* `statuscode`: the link:https://tools.ietf.org/html/rfc2616#section-10[HTTP status code]
+  that the server sent back to the client.
+* `response size`: the number of bytes of data transferred as part of the HTTP
+  response, not including the HTTP header.
+* `latency`: response time in milliseconds.
+* `referer`: the `Referer` HTTP request header. This gives the site that
+  the client reports having been referred from.
+* `client agent`: the client agent which sent the request.
+
+Example:
+```
+12.34.56.78 [HTTP-4136374] - johndoe [28/Aug/2020:10:02:20 +0200] "GET /a/plugins/metrics-reporter-prometheus/metrics HTTP/1.1" 200 1247498 1900 - "Prometheus/2.13.1"
+```
+
+=== SSHD Log
+
+The sshd log tracks ssh requests processed by Gerrit's ssh daemon
+and is written to `$site_path/logs/sshd_log`. Enabled or disabled
+via option link:config-gerrit.html#sshd.requestLog[sshd.requestLog].
+
+Log format:
+
+* `[date time]`: The time that the request was received.
+* `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
+  get all requests from that session.
+* `[thread name]`: name of the Java thread executing the request.
+* `username`: the username used by the client for authentication.
+* `a/accountid`: identifier of the Gerrit account which is logged on.
+* `operation`: the operation being executed via ssh.
+** `LOGIN FROM <host>`: login and start new SSH session from the given host.
+** `AUTH FAILURE FROM <host> <message>`: failed authentication from given host and cause of failure.
+** `LOGOUT`: logout and terminate SSH session.
+** `git-upload-pack.<projectname>`: git fetch or clone command for given project.
+** `git-receive-pack.<projectname>`: git push command for given project.
+** Gerrit ssh commands which may be logged in this field are documented
+   link:cmd-index.html#_server[here].
+* `wait`: command wait time, time in milliseconds the command waited for an execution thread.
+* `exec`: command execution time, time in milliseconds to execute the command.
+* `status`: status code. 0 means success, any other value is an error.
+
+The `git-upload-pack` command provides the following additional fields after the `exec`
+and before the `status` field. All times are in milliseconds. Fields are -1 if not available
+when the upload-pack request returns an empty result since the client's repository was up to date:
+
+* `time negotiating`: time for negotiating which objects need to be transferred.
+* `time searching for reuse`: time jgit searched for deltas which can be reused.
+  That is the time spent matching existing representations against objects that
+  will be transmitted, or that the client can be assumed to already have.
+* `time searching for sizes`: time jgit was searching for sizes of all objects that
+  will enter the delta compression search window. The sizes need to
+  be known to better match similar objects together and improve
+  delta compression ratios.
+* `time counting`: time jgit spent enumerating the objects that need to
+  be included in the output. This time includes any restarts that
+  occur when a cached pack is selected for reuse.
+* `time compressing`: time jgit was compressing objects.  This is observed
+  wall-clock time and does not accurately track CPU time used when
+  multiple threads were used to perform the delta compression.
+* `time writing`: time jgit needed to write packfile, from start of
+  header until end of trailer. The transfer speed can be
+  approximated by dividing `total bytes` by this value.
+* `total time in UploadPack`: total time jgit spent in upload-pack.
+* `bitmap index misses`: number of misses when trying to use bitmap index,
+  -1 means no bitmap index available. This is the count of objects that
+  needed to be discovered through an object walk because they were not found
+  in bitmap indices.
+* `total deltas`: total number of deltas transferred. This may be lower than the actual
+  number of deltas if a cached pack was reused.
+* `total objects`: total number of objects transferred. This total includes
+  the value of `total deltas`.
+* `total bytes`: total number of bytes transferred. This size includes the pack
+  header, trailer, thin pack, and reused cached packs.
+* `client agent`: the client agent and version which sent the request.
+
+Example: a CI system established a SSH connection, sent an upload-pack command (git fetch) and closed the connection:
+```
+[2020-08-28 11:00:01,391 +0200] 8a154cae [sshd-SshServer[570fc452]-nio2-thread-299] voter a/1000023 LOGIN FROM 12.34.56.78
+[2020-08-28 11:00:01,556 +0200] 8a154cae [SSH git-upload-pack /AP/ajs/jpaas-msg-svc.git (voter)] voter a/1000056 git-upload-pack./demo/project.git 0ms 115ms 92ms 1ms 0ms 6ms 0ms 0ms 7ms 3 10 26 2615 0 git/2.26.2
+[2020-08-28 11:00:01,583 +0200] 8a154cae [sshd-SshServer[570fc452]-nio2-thread-168] voter a/1000023 LOGOUT
+```
+
+=== Error Log
+
+The error log tracks errors and stack traces and is written to
+`$site_path/logs/error_log`.
+
+Log format:
+
+* `[date time]`: The time that the request was received.
+* `[thread name]`: : name of the Java thread executing the request.
+* `level`: log level (ERROR, WARN, INFO, DEBUG).
+* `logger`: name of the logger.
+* `message`: log message.
+* `stacktrace`: Java stacktrace when an execption was caught, usually spans multiple lines.
+
+=== GC Log
+
+The gc log tracks git garbage collection running in a background thread
+if enabled and is written to `$site_path/logs/gc_log`.
+
+Log format:
+
+* `[date time]`: The time that the request was received.
+* `level`: log level (ERROR, WARN, INFO, DEBUG).
+* `message`: log message.
+
+=== Plugin Logs
+
+Some plugins write their own log file.
+E.g. the replication plugin writes its log to `$site_path/logs/replication_log`.
+Refer to each plugin's documentation for more details on their logs.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index fbad212..e09d936 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5026,6 +5026,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
 --
@@ -7393,6 +7524,10 @@
 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]]
diff --git a/WORKSPACE b/WORKSPACE
index 6be35cf..4c2fe35 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -219,15 +219,15 @@
     sha1 = GUAVA_BIN_SHA1,
 )
 
-CAFFEINE_VERS = "2.8.0"
+CAFFEINE_VERS = "2.8.5"
 
 maven_jar(
     name = "caffeine",
     artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
-    sha1 = "6000774d7f8412ced005a704188ced78beeed2bb",
+    sha1 = "f0eafef6e1529a44e36549cd9d1fc06d3a57f384",
 )
 
-CAFFEINE_GUAVA_SHA256 = "3a66ee3ec70971dee0bae6e56bda7b8742bc4bedd7489161bfbbaaf7137d89e1"
+CAFFEINE_GUAVA_SHA256 = "a7ce6d29c40bccd688815a6734070c55b20cd326351a06886a6144005aa32299"
 
 # TODO(davido): Rename guava.jar to caffeine-guava.jar on fetch to prevent potential
 # naming collision between caffeine guava adapter and guava library itself.
@@ -758,8 +758,8 @@
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2019-10-08",
-    sha1 = "4518bf8bac2dbbed684849bc209c39c4cb546237",
+    artifact = "com.google.template:soy:2020-08-24",
+    sha1 = "e774bf5cc95923d2685292883fe219e231346e50",
 )
 
 maven_jar(
diff --git a/e2e-tests/build.sbt b/e2e-tests/build.sbt
index a322970..294212c 100644
--- a/e2e-tests/build.sbt
+++ b/e2e-tests/build.sbt
@@ -2,7 +2,6 @@
 
 enablePlugins(GatlingPlugin)
 
-lazy val gatlingGitExtension = RootProject(uri("git://github.com/GerritForge/gatling-git.git"))
 lazy val root = (project in file("."))
     .settings(
       inThisBuild(List(
@@ -12,8 +11,8 @@
       )),
       name := "gerrit",
       libraryDependencies ++=
-          gatling ++
+          gatling ++ gatlingGit ++
               Seq("io.gatling" % "gatling-core" % GatlingVersion) ++
               Seq("io.gatling" % "gatling-app" % GatlingVersion),
       scalacOptions += "-language:postfixOps"
-    ) dependsOn gatlingGitExtension
+    )
diff --git a/e2e-tests/project/Dependencies.scala b/e2e-tests/project/Dependencies.scala
index 63328f9..56ef740 100644
--- a/e2e-tests/project/Dependencies.scala
+++ b/e2e-tests/project/Dependencies.scala
@@ -2,9 +2,17 @@
 
 object Dependencies {
   val GatlingVersion = "3.2.0"
+  val GatlingGitVersion = "1.0.12"
 
   lazy val gatling = Seq(
     "io.gatling.highcharts" % "gatling-charts-highcharts",
     "io.gatling" % "gatling-test-framework",
   ).map(_ % GatlingVersion % Test)
+
+  lazy val gatlingGit = Seq(
+    "com.gerritforge" %% "gatling-git" % GatlingGitVersion excludeAll(
+      ExclusionRule(organization = "io.gatling"),
+      ExclusionRule(organization = "io.gatling.highcharts")
+    )
+  )
 }
diff --git a/e2e-tests/project/plugins.sbt b/e2e-tests/project/plugins.sbt
index 36cd201..9ed0f89 100644
--- a/e2e-tests/project/plugins.sbt
+++ b/e2e-tests/project/plugins.sbt
@@ -1 +1,2 @@
 addSbtPlugin("io.gatling" % "gatling-sbt" % "3.0.0")
+addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4")
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json
index bcf4708..282ac99 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject-body.json
@@ -1,3 +1,4 @@
 {
-  "create_empty_commit": "true"
+  "create_empty_commit": "true",
+  "parent": "${parent}"
 }
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index cd90739..c141bb8 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,5 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT",
+    "parent": "PARENT"
   }
 ]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
index d631292..f2b3d12 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateProject.scala
@@ -28,7 +28,7 @@
 
   val test: ScenarioBuilder = scenario(unique)
       .feed(data)
-      .exec(httpRequest.body(RawFileBody(body)).asJson)
+      .exec(httpRequest.body(ElFileBody(body)).asJson)
 
   setUp(
     test.inject(
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index 4832392..679320d 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -67,6 +67,8 @@
     case ("number", number) =>
       val precedes = replaceKeyWith("_number", 0, number.toString)
       replaceProperty("number", 1, precedes)
+    case ("parent", parent) =>
+      replaceProperty("parent", "All-Projects", parent.toString)
     case ("project", project) =>
       var precedes = replaceKeyWith("_project", name, project.toString)
       precedes = replaceOverride(precedes)
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 357ea0c..78a621c 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -429,13 +429,16 @@
 
     baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
+    Module auditModule = createAuditModule();
     if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
       if (commonServer == null) {
-        commonServer = GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module);
+        commonServer =
+            GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module, auditModule);
       }
       server = commonServer;
     } else {
-      server = GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module);
+      server =
+          GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module, auditModule);
     }
 
     server.getTestInjector().injectMembers(this);
@@ -528,6 +531,11 @@
     return null;
   }
 
+  /** Override to bind an alternative audit Guice module */
+  public Module createAuditModule() {
+    return null;
+  }
+
   protected void initSsh() throws Exception {
     if (testRequiresSsh
         && SshMode.useSsh()
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 5206957..5942c0f 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -35,6 +35,7 @@
 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;
@@ -329,14 +330,15 @@
       TemporaryFolder temporaryFolder,
       Description desc,
       Config baseConfig,
-      @Nullable Module testSysModule)
+      @Nullable Module testSysModule,
+      @Nullable Module testAuditModule)
       throws Exception {
     Path site = temporaryFolder.newFolder().toPath();
     try {
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site, testSysModule, null);
+      return start(desc, baseConfig, site, testSysModule, testAuditModule, null);
     } catch (Exception e) {
       throw e;
     }
@@ -364,6 +366,7 @@
       Config baseConfig,
       Path site,
       @Nullable Module testSysModule,
+      @Nullable Module testAuditModule,
       @Nullable InMemoryRepositoryManager inMemoryRepoManager,
       String... additionalArgs)
       throws Exception {
@@ -382,7 +385,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);
     }
@@ -515,6 +519,7 @@
             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());
@@ -609,7 +614,7 @@
 
     server.close();
     server.daemon.stop();
-    return start(server.desc, cfg, site, null, inMemoryRepoManager);
+    return start(server.desc, cfg, site, null, null, inMemoryRepoManager);
   }
 
   private static boolean hasBinding(Injector injector, Class<?> clazz) {
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index c38f5fa..43fe4eb 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -187,7 +187,7 @@
   private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
       throws Exception {
     return GerritServer.start(
-        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, additionalArgs);
+        serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, null, additionalArgs);
   }
 
   protected static void runGerrit(String... args) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
index aa0391d..c4e4192 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
@@ -127,5 +127,13 @@
      * @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
index 13392d8..3b15b57 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -25,6 +25,7 @@
 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;
@@ -52,12 +53,16 @@
 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;
@@ -82,6 +87,7 @@
   private final PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory;
   private final PerCommentOperationsImpl.Factory perCommentOperationsFactory;
   private final PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory;
+  private final PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory;
 
   @Inject
   public ChangeOperationsImpl(
@@ -97,7 +103,8 @@
       ChangeFinder changeFinder,
       PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory,
       PerCommentOperationsImpl.Factory perCommentOperationsFactory,
-      PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory) {
+      PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory,
+      PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory) {
     this.seq = seq;
     this.changeInserterFactory = changeInserterFactory;
     this.patchsetInserterFactory = patchsetInserterFactory;
@@ -111,6 +118,7 @@
     this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
     this.perCommentOperationsFactory = perCommentOperationsFactory;
     this.perDraftCommentOperationsFactory = perDraftCommentOperationsFactory;
+    this.perRobotCommentOperationsFactory = perRobotCommentOperationsFactory;
   }
 
   @Override
@@ -196,52 +204,154 @@
       TestChangeCreation changeCreation,
       PersonIdent authorAndCommitter)
       throws IOException, BadRequestException {
-    Optional<ObjectId> branchTip = getTip(repository, changeCreation.branch());
-
-    ObjectId tree =
-        createNewTree(
-            repository,
-            revWalk,
-            branchTip.orElse(ObjectId.zeroId()),
-            changeCreation.treeModifications());
-
+    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());
-
-    ImmutableList<ObjectId> parentCommitIds = Streams.stream(branchTip).collect(toImmutableList());
     return createCommit(
-        objectInserter,
-        tree,
-        parentCommitIds,
-        authorAndCommitter,
-        authorAndCommitter,
-        commitMessage);
+        objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
   }
 
-  private Optional<ObjectId> getTip(Repository repository, String branch) throws IOException {
-    Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
-    return ref.map(Ref::getObjectId);
+  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,
-      RevWalk revWalk,
-      ObjectId baseCommitId,
+      TreeCreator treeCreator,
       ImmutableList<TreeModification> treeModifications)
       throws IOException {
-    TreeCreator treeCreator = getTreeCreator(revWalk, baseCommitId);
     treeCreator.addTreeModifications(treeModifications);
     return treeCreator.createNewTreeAndGetId(repository);
   }
 
-  private static TreeCreator getTreeCreator(RevWalk revWalk, ObjectId baseCommitId)
-      throws IOException {
-    if (ObjectId.zeroId().equals(baseCommitId)) {
-      return TreeCreator.basedOnEmptyTree();
-    }
-    RevCommit baseCommit = revWalk.parseCommit(baseCommitId);
-    return TreeCreator.basedOn(baseCommit);
-  }
-
   private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException {
     String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
 
@@ -352,16 +462,16 @@
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
       RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
 
-      ObjectId tree =
-          createNewTree(
-              repository, revWalk, oldPatchsetCommitId, patchsetCreation.treeModifications());
+      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));
 
-      ImmutableList<ObjectId> parentCommitIds = getParents(oldPatchsetCommit);
       PersonIdent author = getAuthor(oldPatchsetCommit);
       PersonIdent committer = getCommitter(oldPatchsetCommit, now);
       return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
@@ -404,10 +514,16 @@
       return date.getTime() / 1000;
     }
 
-    private ImmutableList<ObjectId> getParents(RevCommit oldPatchsetCommit) {
-      return Arrays.stream(oldPatchsetCommit.getParents())
-          .map(ObjectId::toObjectId)
-          .collect(toImmutableList());
+    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(
@@ -442,5 +558,11 @@
       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/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/PerCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java
index 5f046ca..0218731 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java
@@ -56,6 +56,11 @@
   }
 
   static TestHumanComment toTestComment(HumanComment comment) {
-    return TestHumanComment.builder().uuid(comment.key.uuid).parentUuid(comment.parentUuid).build();
+    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/PerPatchsetOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
index 33d2d43..f4c70bd 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
@@ -67,4 +67,25 @@
    * @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
index d39f1e1..465c419 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -16,13 +16,13 @@
 
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
-import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation.CommentSide;
 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;
@@ -101,6 +101,11 @@
     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();
@@ -126,6 +131,20 @@
     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;
@@ -154,11 +173,13 @@
       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(),
-              context.getWhen(),
+              createdOn,
               filePath,
               patchsetId,
               side,
@@ -173,7 +194,7 @@
       // Specification of range trumps explicit line specification.
       commentCreation
           .range()
-          .map(this::toCommentRange)
+          .map(PerPatchsetOperationsImpl::toCommentRange)
           .ifPresent(range -> newComment.setLineNbrAndRange(null, range));
 
       setCommentCommitId(
@@ -183,14 +204,89 @@
           changeNotes.getPatchSets().get(patchsetId));
       return newComment;
     }
+  }
 
-    private 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 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) throws Exception {
+      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)
+        throws PatchListNotAvailableException {
+      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
+          .unresolved()
+          .ifPresent(unresolved -> newRobotComment.unresolved = unresolved);
+      robotCommentCreation
+          .parentUuid()
+          .ifPresent(parentUuid -> newRobotComment.parentUuid = parentUuid);
+      robotCommentCreation.url().ifPresent(url -> newRobotComment.url = url);
+      if (!robotCommentCreation.properties().isEmpty()) {
+        newRobotComment.properties = robotCommentCreation.properties();
+      }
+
+      setCommentCommitId(
+          newRobotComment,
+          patchListCache,
+          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..c9a0eff
--- /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<T>(fileFunction);
+        });
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index 65db967..5871e17 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -23,6 +23,7 @@
 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
@@ -37,13 +38,19 @@
 
   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");
+        .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
@@ -72,6 +79,53 @@
 
     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();
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
index d1d1567..2031bde 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
@@ -21,11 +21,16 @@
 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;
-import java.util.function.Function;
-import java.util.function.IntFunction;
 
-/** Attributes of the comment. If not provided, arbitrary values will be used. */
+/**
+ * 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 {
 
@@ -47,11 +52,13 @@
 
   public abstract Optional<Account.Id> author();
 
+  public abstract Optional<Instant> createdOn();
+
   abstract Comment.Status status();
 
   abstract ThrowingFunction<TestCommentCreation, String> commentCreator();
 
-  public static TestCommentCreation.Builder builder(
+  public static Builder builder(
       ThrowingFunction<TestCommentCreation, String> commentCreator, Comment.Status commentStatus) {
     return new AutoValue_TestCommentCreation.Builder()
         .commentCreator(commentCreator)
@@ -82,8 +89,8 @@
      * Starts the fluent change to create a line comment. The line comment will be at the indicated
      * line. Lines start with 1.
      */
-    public FileBuilder onLine(int line) {
-      return new FileBuilder(file -> file(file).line(line).range(null));
+    public FileBuilder<Builder> onLine(int line) {
+      return new FileBuilder<>(file -> file(file).line(line).range(null));
     }
 
     /**
@@ -91,12 +98,12 @@
      * Lines start at 1. The start position (line, charOffset) is inclusive, the end position (line,
      * charOffset) is exclusive.
      */
-    public PositionBuilder<StartAwarePositionBuilder> fromLine(int startLine) {
+    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(this, testRangeBuilder);
+            return new StartAwarePositionBuilder<>(testRangeBuilder, this::range, this::file);
           });
     }
 
@@ -173,14 +180,29 @@
     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 TestCommentCreation.Builder commentCreator(
-        ThrowingFunction<TestCommentCreation, String> commentCreator);
+    abstract Builder commentCreator(ThrowingFunction<TestCommentCreation, String> commentCreator);
 
     abstract TestCommentCreation autoBuild();
 
@@ -194,72 +216,4 @@
       return commentCreation.commentCreator().applyAndThrowSilently(commentCreation);
     }
   }
-
-  /** Builder for the file specification of line/range comments. */
-  public static class FileBuilder {
-    private final Function<String, Builder> nextStepProvider;
-
-    private FileBuilder(Function<String, Builder> nextStepProvider) {
-      this.nextStepProvider = nextStepProvider;
-    }
-
-    /** File on which the comment should be added. */
-    public Builder ofFile(String file) {
-      return nextStepProvider.apply(file);
-    }
-  }
-
-  /** Builder to simplify a position specification. */
-  public static class PositionBuilder<T> {
-    private final IntFunction<T> nextStepProvider;
-
-    private 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);
-    }
-  }
-
-  /** Builder for the end position of a range. */
-  public static class StartAwarePositionBuilder {
-    private final TestCommentCreation.Builder testCommentCreationBuilder;
-    private final TestRange.Builder testRangeBuilder;
-
-    private StartAwarePositionBuilder(
-        Builder testCommentCreationBuilder, TestRange.Builder testRangeBuilder) {
-      this.testCommentCreationBuilder = testCommentCreationBuilder;
-      this.testRangeBuilder = testRangeBuilder;
-    }
-
-    /** Line of the end position of the range. */
-    public PositionBuilder<FileBuilder> toLine(int endLine) {
-      return new PositionBuilder<>(
-          endCharOffset -> {
-            Position end = Position.builder().line(endLine).charOffset(endCharOffset).build();
-            TestRange range = testRangeBuilder.setEnd(end).build();
-            testCommentCreationBuilder.range(range);
-            return new FileBuilder(testCommentCreationBuilder::file);
-          });
-    }
-  }
-
-  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/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
index 9bb026f..3a7f2ae 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.java
@@ -28,6 +28,12 @@
   /** 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();
   }
@@ -38,6 +44,10 @@
 
     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/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index 0152cd1..fe9d909 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -29,6 +29,8 @@
 
   public abstract ImmutableList<TreeModification> treeModifications();
 
+  public abstract Optional<ImmutableList<TestCommitIdentifier>> parents();
+
   abstract ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator();
 
   public static TestPatchsetCreation.Builder builder(
@@ -48,6 +50,37 @@
 
     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);
 
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..809190d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotCommentCreation.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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<Boolean> unresolved();
+
+  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);
+
+    /** 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);
+
+    /** 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/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/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
index dd226ed..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,7 @@
 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;
@@ -59,6 +60,26 @@
     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/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 6f8a863..77d02c1 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -68,7 +68,7 @@
 
     Set<String> enabledExperiments = experimentData(urlParameterMap);
     if (!enabledExperiments.isEmpty()) {
-      data.put("enabledExperiments", serializeObject(GSON, enabledExperiments));
+      data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
     return data.build();
   }
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 64a6aa3..28407a7 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -491,12 +491,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/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 81969f0..4d5778a 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -21,7 +21,6 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.Nullable;
@@ -51,12 +50,9 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 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.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -188,6 +184,12 @@
         .findFirst();
   }
 
+  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))
@@ -204,6 +206,10 @@
     return sort(Lists.newArrayList(notes.getRobotComments().values()));
   }
 
+  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())) {
@@ -343,43 +349,6 @@
     update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
   }
 
-  /**
-   * Gets all of the {@link HumanComment} in the comment threads that received a reply.
-   *
-   * @param changeNotes notes of this change.
-   * @param newComments set of all the new comments added on the change by the current user.
-   * @return set of all comments in the comments thread that received a reply.
-   */
-  public Set<HumanComment> getAllHumanCommentsInCommentThreads(
-      ChangeNotes changeNotes, ImmutableSet<HumanComment> newComments) {
-    Map<String, HumanComment> uuidToComment =
-        publishedHumanCommentsByChange(changeNotes).stream()
-            .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-
-    // Copy the set so that it won't be mutated.
-    List<HumanComment> toTraverse = new ArrayList<>(newComments);
-    Set<String> seen = new HashSet<>();
-    Set<HumanComment> allCommentsInCommentThreads = new HashSet<>();
-    while (!toTraverse.isEmpty()) {
-      HumanComment current = toTraverse.remove(0);
-      allCommentsInCommentThreads.add(current);
-
-      if (current.parentUuid != null) {
-        HumanComment parent = uuidToComment.get(current.parentUuid);
-        if (parent == null) {
-          // If we can't find the parent within the human comments, the parent must be a robot
-          // comment and can be ignored.
-          continue;
-        }
-        if (!seen.contains(current.parentUuid)) {
-          toTraverse.add(parent);
-          seen.add(current.parentUuid);
-        }
-      }
-    }
-    return allCommentsInCommentThreads;
-  }
-
   private static List<HumanComment> commentsOnFile(
       Collection<HumanComment> allComments, String file) {
     List<HumanComment> result = new ArrayList<>(allComments.size());
@@ -411,15 +380,33 @@
         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));
-        }
+      c.setCommitId(determineCommitId(cache, change, ps, c.side));
+    }
+  }
+
+  /**
+   * Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
+   *
+   * @param patchListCache the cache to use for SHA-1 lookups
+   * @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
+   * @throws PatchListNotAvailableException if the SHA-1 is unavailable for an unknown reason
+   */
+  public static ObjectId determineCommitId(
+      PatchListCache patchListCache, Change change, PatchSet patchset, short side)
+      throws PatchListNotAvailableException {
+    if (Side.fromShort(side) == Side.PARENT) {
+      if (side < 0) {
+        return patchListCache.getOldId(change, patchset, -side);
       } else {
-        c.setCommitId(ps.commitId());
+        return patchListCache.getOldId(change, patchset, null);
       }
+    } else {
+      return patchset.commitId();
     }
   }
 
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/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index 6a48cce..3f7f3f2 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountsSection;
@@ -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/change/CommentThread.java b/java/com/google/gerrit/server/change/CommentThread.java
new file mode 100644
index 0000000..7b729d2
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentThread.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Comment;
+import java.util.List;
+
+/**
+ * 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() {
+    return Iterables.getLast(comments()).unresolved;
+  }
+
+  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/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index e0648cf..19a495d 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -20,6 +20,7 @@
 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 {
@@ -50,6 +51,10 @@
     return comment;
   }
 
+  public ChangeNotes getNotes() {
+    return rev.getNotes();
+  }
+
   public String getId() {
     return comment.key.uuid;
   }
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 2459d0b..2b363f1 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -156,7 +156,7 @@
 
   public Optional<GroupReference> getGroupReference(String name) {
     String exactName = GroupReference.extractGroupName(getString(name));
-    return groupReferences().values().stream().filter(g -> exactName.equals(g.getName())).findAny();
+    return groupReferences().values().stream().filter(g -> g.getName().equals(exactName)).findAny();
   }
 
   /** Mutable representation of {@link PluginConfig}. Used for updates. */
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index d3f90e5..5054da6 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -57,17 +57,10 @@
     return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=findings");
   }
 
-  /** 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) {
+  /** 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 + "/" + patchsetId + "/" + filename);
-  }
-
-  /** 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));
+        .map(url -> url + "/comment/" + uuid);
   }
 
   /** Returns a URL pointing to the settings page. */
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index 45f877e..dfc1ffb 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -46,6 +46,12 @@
     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());
   }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 0f46199..47cbd60 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -265,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);
 
@@ -284,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);
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 7d5f3fa..ac6c2f3 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.KeyUtil;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
@@ -73,20 +72,9 @@
     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 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 a comment for a change. */
+    public String getCommentLink(String uuid) {
+      return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
     }
 
     /** @return a web link to the comment tab view of a change. */
@@ -389,9 +377,6 @@
 
     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
-      if (!group.filename.equals(Patch.PATCHSET_LEVEL)) {
-        groupData.put("link", group.getFileLink());
-      }
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
 
@@ -426,10 +411,8 @@
           } else {
             commentData.put("link", group.getCommentsTabLink());
           }
-        } else if (comment.lineNbr == 0) {
-          commentData.put("link", group.getFileLink());
         } else {
-          commentData.put("link", group.getCommentLink(comment.side, startLine));
+          commentData.put("link", group.getCommentLink(comment.key.uuid));
         }
 
         // Set robot comment data.
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index b61937b..c3b0e79 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -787,12 +787,14 @@
             AttentionSetUpdate.createForWrite(
                 reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
       }
-      // Treat both REMOVED and CC as "removed reviewers".
-      if (!reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
-          && currentReviewers.contains(reviewer.getKey())) {
+      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 was removed"));
+                reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
       }
     }
     addToPlannedAttentionSetUpdates(updates);
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 7f7df8c..8301576 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -60,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;
@@ -790,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/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 5fab976..fdac552 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -177,6 +177,7 @@
         TimeUtil.nowTs(),
         null,
         null,
+        null,
         null);
   }
 
@@ -208,7 +209,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);
   }
 
   /**
@@ -249,7 +250,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 {
 
@@ -370,7 +372,8 @@
                     destChanges.get(0).notes(),
                     cherryPickCommit,
                     sourceChange.currentPatchSetId(),
-                    newTopic);
+                    newTopic,
+                    workInProgress);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -385,7 +388,8 @@
                     sourceCommit,
                     input,
                     revertedChange,
-                    idForNewChange);
+                    idForNewChange,
+                    workInProgress);
           }
           bu.execute();
           return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -453,13 +457,17 @@
       ChangeNotes destNotes,
       CodeReviewCommit cherryPickCommit,
       PatchSet.Id sourcePatchSetId,
-      String topic)
+      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
         || !destChange.getCherryPickOf().equals(sourcePatchSetId)) {
@@ -479,11 +487,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(
@@ -493,10 +509,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/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 9a009d1..05241e2 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -20,16 +20,22 @@
 
 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.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
 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;
@@ -39,9 +45,12 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Container for all logic necessary to port comments to a target patchset.
@@ -53,6 +62,7 @@
  */
 @Singleton
 public class CommentPorter {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
@@ -87,25 +97,31 @@
    * @return the ported comments, in no particular order
    */
   public ImmutableList<HumanComment> portComments(
-      ChangeNotes changeNotes, PatchSet targetPatchset, List<HumanComment> comments)
-      throws PatchListNotAvailableException {
+      ChangeNotes changeNotes, PatchSet targetPatchset, List<HumanComment> comments) {
 
     ImmutableList<HumanComment> relevantComments = filterToRelevant(comments, targetPatchset);
-    return portToTargetPatchset(changeNotes, targetPatchset, relevantComments);
+    return port(changeNotes, targetPatchset, relevantComments);
   }
 
   private ImmutableList<HumanComment> filterToRelevant(
       List<HumanComment> allComments, PatchSet targetPatchset) {
-    return allComments.stream()
-        .filter(comment -> comment.key.patchSetId < targetPatchset.number())
-        // TODO(aliceks): Also support comments on parent revisions.
-        .filter(comment -> comment.side > 0)
+    ImmutableList<HumanComment> previousPatchsetsComments =
+        allComments.stream()
+            .filter(comment -> comment.key.patchSetId < targetPatchset.number())
+            .collect(toImmutableList());
+
+    ImmutableSet<CommentThread<HumanComment>> commentThreads =
+        CommentThreads.forComments(previousPatchsetsComments).getThreads();
+
+    return commentThreads.stream()
+        .filter(CommentThread::unresolved)
+        .map(CommentThread::comments)
+        .flatMap(Collection::stream)
         .collect(toImmutableList());
   }
 
-  private ImmutableList<HumanComment> portToTargetPatchset(
-      ChangeNotes notes, PatchSet targetPatchset, List<HumanComment> comments)
-      throws PatchListNotAvailableException {
+  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()));
 
@@ -116,20 +132,60 @@
       PatchSet originalPatchset =
           notes.getPatchSets().get(PatchSet.id(notes.getChangeId(), originalPatchsetId));
       portedComments.addAll(
-          portToTargetPatchset(
-              notes.getProjectName(), originalPatchset, targetPatchset, patchsetComments));
+          portSamePatchset(
+              notes.getProjectName(),
+              notes.getChange(),
+              originalPatchset,
+              targetPatchset,
+              patchsetComments));
     }
     return portedComments.build();
   }
 
-  private ImmutableList<HumanComment> portToTargetPatchset(
+  private ImmutableList<HumanComment> portSamePatchset(
       Project.NameKey project,
+      Change change,
       PatchSet originalPatchset,
       PatchSet targetPatchset,
-      ImmutableList<HumanComment> comments)
-      throws PatchListNotAvailableException {
-    ImmutableSet<Mapping> mappings =
-        loadPatchsetMappings(project, originalPatchset, 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()
@@ -137,17 +193,41 @@
         .collect(toImmutableList());
   }
 
-  private ImmutableSet<Mapping> loadPatchsetMappings(
-      Project.NameKey project, PatchSet originalPatchset, PatchSet targetPatchset)
+  private ImmutableSet<Mapping> loadMappings(
+      Project.NameKey project,
+      Change change,
+      PatchSet originalPatchset,
+      PatchSet targetPatchset,
+      short side)
+      throws PatchListNotAvailableException {
+    ObjectId originalCommit =
+        CommentsUtil.determineCommitId(patchListCache, change, originalPatchset, side);
+    ObjectId targetCommit =
+        CommentsUtil.determineCommitId(patchListCache, change, targetPatchset, side);
+    return loadCommitMappings(project, originalCommit, targetCommit);
+  }
+
+  private ImmutableSet<Mapping> loadCommitMappings(
+      Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
       throws PatchListNotAvailableException {
     PatchList patchList =
         patchListCache.get(
-            PatchListKey.againstCommit(
-                originalPatchset.commitId(), targetPatchset.commitId(), Whitespace.IGNORE_NONE),
+            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);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index b749e6a..399da8f 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -81,6 +81,11 @@
       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 =
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedComments.java b/java/com/google/gerrit/server/restapi/change/ListPortedComments.java
index 6494d30..ec1dc1d 100644
--- a/java/com/google/gerrit/server/restapi/change/ListPortedComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedComments.java
@@ -22,7 +22,6 @@
 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.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -47,7 +46,7 @@
 
   @Override
   public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
-      throws PermissionBackendException, PatchListNotAvailableException {
+      throws PermissionBackendException {
     PatchSet targetPatchset = revisionResource.getPatchSet();
 
     List<HumanComment> allComments =
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
index 828229c..b5542bf 100644
--- a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
@@ -22,7 +22,6 @@
 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.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -47,7 +46,7 @@
 
   @Override
   public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
-      throws PermissionBackendException, PatchListNotAvailableException {
+      throws PermissionBackendException {
     PatchSet targetPatchset = revisionResource.getPatchSet();
 
     List<HumanComment> draftComments =
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 4a6dfda..7a4a891 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -615,6 +615,7 @@
         ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
         ensureRangeIsValid(path, comment.range);
         ensureValidPatchsetLevelComment(path, comment);
+        ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
       }
     }
   }
@@ -661,6 +662,16 @@
     }
   }
 
+  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 {
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index f327f16..39de43d 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -86,8 +86,12 @@
       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);
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 1f6574f..c523036 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -17,6 +17,8 @@
 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;
@@ -32,6 +34,8 @@
 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;
@@ -44,6 +48,7 @@
 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;
@@ -208,19 +213,22 @@
   /** Adds all authors of all comment threads that received a reply during this update */
   private void addAllAuthorsOfCommentThreads(
       BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
-    Set<HumanComment> allCommentsInCommentThreads =
-        commentsUtil.getAllHumanCommentsInCommentThreads(changeNotes, allNewComments);
-    // Copy the set to make it mutable, so that we can delete users that were already added.
-    Set<Account.Id> possibleUsersToAdd =
-        new HashSet<>(approvalsUtil.getReviewers(changeNotes).all());
+    List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
+    ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
+        CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
 
-    for (HumanComment comment : allCommentsInCommentThreads) {
-      Account.Id author = comment.author.getId();
-      if (possibleUsersToAdd.contains(author)) {
-        addToAttentionSet(
-            bu, changeNotes, author, "Someone else replied on a comment you posted", false);
-        possibleUsersToAdd.remove(author);
-      }
+    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);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index cb3a375..ca39a57 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -203,6 +203,7 @@
 
     checkPermissionsForAllChanges(changeResource, changeDatas);
     input.topic = createTopic(input.topic, submissionId);
+
     return Response.ok(revertSubmission(changeDatas, input));
   }
 
@@ -258,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);
 
@@ -332,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,
@@ -549,16 +557,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
@@ -575,7 +586,8 @@
               timestamp,
               change.getId(),
               computedChangeId,
-              cherryPickRevertChangeId);
+              cherryPickRevertChangeId,
+              workInProgress);
       // save the commit as base for next cherryPick of that branch
       cherryPickInput.base =
           changeNotesFactory
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index 5145c13..c43bf91 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -62,7 +62,9 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.mina.transport.socket.SocketSessionConfig;
 import org.apache.sshd.common.BaseBuilder;
@@ -72,6 +74,8 @@
 import org.apache.sshd.common.compression.BuiltinCompressions;
 import org.apache.sshd.common.compression.Compression;
 import org.apache.sshd.common.forward.DefaultForwarderFactory;
+import org.apache.sshd.common.future.CloseFuture;
+import org.apache.sshd.common.future.SshFutureListener;
 import org.apache.sshd.common.io.AbstractIoServiceFactory;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoServiceFactory;
@@ -142,6 +146,7 @@
   private final List<HostKey> hostKeys;
   private volatile IoAcceptor daemonAcceptor;
   private final Config cfg;
+  private final long gracefulStopTimeout;
 
   @Inject
   SshDaemon(
@@ -212,6 +217,8 @@
     SshSessionBackend backend = cfg.getEnum("sshd", null, "backend", SshSessionBackend.NIO2);
     boolean channelIdTracking = cfg.getBoolean("sshd", "enableChannelIdTracking", true);
 
+    gracefulStopTimeout = cfg.getTimeUnit("sshd", null, "gracefulStopTimeout", 0, TimeUnit.SECONDS);
+
     System.setProperty(
         IoServiceFactoryFactory.class.getName(),
         backend == SshSessionBackend.MINA
@@ -341,6 +348,12 @@
   public synchronized void stop() {
     if (daemonAcceptor != null) {
       try {
+        if (gracefulStopTimeout > 0) {
+          logger.atInfo().log(
+              "Stopping SSHD sessions gracefully with %d seconds timeout.", gracefulStopTimeout);
+          daemonAcceptor.unbind(daemonAcceptor.getBoundAddresses());
+          waitForSessionClose();
+        }
         daemonAcceptor.close(true).await();
         shutdownExecutors();
         logger.atInfo().log("Stopped Gerrit SSHD");
@@ -352,6 +365,30 @@
     }
   }
 
+  private void waitForSessionClose() {
+    Collection<IoSession> ioSessions = daemonAcceptor.getManagedSessions().values();
+    CountDownLatch allSessionsClosed = new CountDownLatch(ioSessions.size());
+    for (IoSession io : ioSessions) {
+      logger.atFine().log("Waiting for session %s to stop.", io.getId());
+      io.addCloseFutureListener(
+          new SshFutureListener<CloseFuture>() {
+            @Override
+            public void operationComplete(CloseFuture future) {
+              allSessionsClosed.countDown();
+            }
+          });
+    }
+    try {
+      if (!allSessionsClosed.await(gracefulStopTimeout, TimeUnit.SECONDS)) {
+        logger.atWarning().log(
+            "Timeout waiting for SSH session to close. SSHD will be shut down immediately.");
+      }
+    } catch (InterruptedException e) {
+      logger.atWarning().withCause(e).log(
+          "Interrupted waiting for SSH-sessions to close. SSHD will be shut down immediately.");
+    }
+  }
+
   private void shutdownExecutors() {
     if (executor != null) {
       executor.shutdownNow();
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index 889bc18..76a5521 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;
@@ -148,13 +149,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;
     reviewInput.tag = ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX;
-    gApi.changes().id(targetChangeId).current().review(reviewInput);
+    return reviewInput;
   }
 }
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/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/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 69278b4..a3a089f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -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());
@@ -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);
@@ -1294,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/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 718d745..783fa49 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -27,6 +27,7 @@
 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;
@@ -37,10 +38,11 @@
 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 {
@@ -57,9 +59,9 @@
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
     // Add comments.
-    String comment1Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
-    changeOps.change(changeId).patchset(patchset2Id).newComment().create();
-    changeOps.change(changeId).patchset(patchset3Id).newComment().create();
+    String comment1Uuid = newComment(patchset1Id).create();
+    newComment(patchset2Id).create();
+    newComment(patchset3Id).create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -75,8 +77,8 @@
     PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
     PatchSet.Id patchset4Id = changeOps.change(changeId).newPatchset().create();
     // Add comments.
-    String comment1Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
-    String comment3Uuid = changeOps.change(changeId).patchset(patchset3Id).newComment().create();
+    String comment1Uuid = newComment(patchset1Id).create();
+    String comment3Uuid = newComment(patchset3Id).create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset4Id));
 
@@ -92,8 +94,8 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comments.
-    String comment1Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
-    String comment2Uuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+    String comment1Uuid = newComment(patchset1Id).create();
+    String comment2Uuid = newComment(patchset1Id).create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -109,21 +111,9 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comments.
-    String rootCommentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
-    String child1CommentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .parentUuid(rootCommentUuid)
-            .create();
-    String child2CommentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .parentUuid(child1CommentUuid)
-            .create();
+    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));
 
@@ -133,17 +123,14 @@
   }
 
   @Test
-  // TODO(aliceks): Filter out unresolved comment threads.
-  @Ignore
-  public void onlyUnresolvedCommentsArePorted() throws Exception {
+  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.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().resolved().create();
-    String comment2Uuid =
-        changeOps.change(changeId).patchset(patchset1Id).newComment().unresolved().create();
+    newComment(patchset1Id).resolved().create();
+    String comment2Uuid = newComment(patchset1Id).unresolved().create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -151,33 +138,34 @@
   }
 
   @Test
-  // TODO(aliceks): Filter out unresolved comment threads.
-  @Ignore
+  public void onlyUnresolvedDraftCommentsArePorted() 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.
+    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(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 =
-        changeOps.change(changeId).patchset(patchset1Id).newComment().resolved().create();
+    String rootComment1Uuid = newComment(patchset1Id).resolved().create();
     String childComment1Uuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .parentUuid(rootComment1Uuid)
-            .unresolved()
-            .create();
-    String rootComment2Uuid =
-        changeOps.change(changeId).patchset(patchset1Id).newComment().unresolved().create();
-    changeOps
-        .change(changeId)
-        .patchset(patchset1Id)
-        .newComment()
-        .parentUuid(rootComment2Uuid)
-        .resolved()
-        .create();
+        newComment(patchset1Id).parentUuid(rootComment1Uuid).unresolved().create();
+    String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
+    newComment(patchset1Id).parentUuid(rootComment2Uuid).resolved().create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -192,24 +180,21 @@
     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 =
-        changeOps.change(changeId).patchset(patchset1Id).newComment().resolved().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 =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .parentUuid(rootCommentUuid)
             .resolved()
+            .createdOn(now.plusSeconds(5))
             .create();
     String childComment2Uuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .parentUuid(rootCommentUuid)
             .unresolved()
+            .createdOn(now.plusSeconds(10))
             .create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
@@ -220,6 +205,30 @@
   }
 
   @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.
@@ -227,7 +236,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add draft comment.
-    changeOps.change(changeId).patchset(patchset1Id).newDraftComment().author(accountId).create();
+    newDraftComment(patchset1Id).author(accountId).create();
 
     // Draft comments are only visible to their author.
     requestScopeOps.setApiUser(accountId);
@@ -244,7 +253,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().author(accountId).create();
+    newComment(patchset1Id).author(accountId).create();
 
     List<CommentInfo> portedComments =
         flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
@@ -260,7 +269,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add draft comment.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().author(accountId).create();
+    newComment(patchset1Id).author(accountId).create();
 
     List<CommentInfo> portedComments =
         flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
@@ -277,7 +286,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add draft comment.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().author(otherUserId).create();
+    newComment(patchset1Id).author(otherUserId).create();
 
     List<CommentInfo> portedComments = flatten(getPortedDraftCommentsOfUser(patchset2Id, userId));
 
@@ -292,10 +301,7 @@
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comments.
     String rangeCommentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .message("Range comment")
             .fromLine(1)
             .charOffset(2)
@@ -304,30 +310,11 @@
             .ofFile("myFile")
             .create();
     String lineCommentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .message("Line comment")
-            .onLine(1)
-            .ofFile("myFile")
-            .create();
+        newComment(patchset1Id).message("Line comment").onLine(1).ofFile("myFile").create();
     String fileCommentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .message("File comment")
-            .onFileLevelOf("myFile")
-            .create();
+        newComment(patchset1Id).message("File comment").onFileLevelOf("myFile").create();
     String patchsetLevelCommentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .message("Patchset-level comment")
-            .onPatchsetLevel()
-            .create();
+        newComment(patchset1Id).message("Patchset-level comment").onPatchsetLevel().create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -337,20 +324,18 @@
             rangeCommentUuid, lineCommentUuid, fileCommentUuid, patchsetLevelCommentUuid);
   }
 
-  // This is not the desired behavior but at least a current state which doesn't throw exceptions
-  // or has wrong behavior.
   @Test
-  public void commentOnParentCommitIsIgnored() throws Exception {
+  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.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().onParentCommit().create();
+    String commentUuid = newComment(patchset1Id).onParentCommit().create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
-    assertThat(portedComments).isEmpty();
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
   }
 
   @Test
@@ -360,7 +345,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+    String commentUuid = newComment(patchset1Id).create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -374,7 +359,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+    String commentUuid = newComment(patchset1Id).create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -389,13 +374,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newDraftComment()
-            .author(authorId)
-            .create();
+    String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
 
     Map<String, List<CommentInfo>> portedComments =
         getPortedDraftCommentsOfUser(patchset2Id, authorId);
@@ -414,8 +393,7 @@
     TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid =
-        changeOps.change(changeId).patchset(patchset1.patchsetId()).newComment().create();
+    String commentUuid = newComment(patchset1.patchsetId()).create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -429,13 +407,7 @@
     TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1.patchsetId())
-            .newComment()
-            .message("My comment text")
-            .create();
+    String commentUuid = newComment(patchset1.patchsetId()).message("My comment text").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -449,15 +421,9 @@
     TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comments.
-    String rootCommentUuid =
-        changeOps.change(changeId).patchset(patchset1.patchsetId()).newComment().create();
+    String rootCommentUuid = newComment(patchset1.patchsetId()).create();
     String childCommentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1.patchsetId())
-            .newComment()
-            .parentUuid(rootCommentUuid)
-            .create();
+        newComment(patchset1.patchsetId()).parentUuid(rootCommentUuid).create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, childCommentUuid);
 
@@ -472,8 +438,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid =
-        changeOps.change(changeId).patchset(patchset1Id).newComment().author(authorId).create();
+    String commentUuid = newComment(patchset1Id).author(authorId).create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -488,13 +453,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newDraftComment()
-            .author(authorId)
-            .create();
+    String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
 
     Map<String, List<CommentInfo>> portedComments =
         getPortedDraftCommentsOfUser(patchset2Id, authorId);
@@ -511,13 +470,7 @@
     TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1.patchsetId())
-            .newComment()
-            .tag("My comment tag")
-            .create();
+    String commentUuid = newComment(patchset1.patchsetId()).tag("My comment tag").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -531,7 +484,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+    String commentUuid = newComment(patchset1Id).create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -545,7 +498,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+    String commentUuid = newComment(patchset1Id).create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -562,13 +515,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onFileLevelOf("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -592,10 +539,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -626,10 +570,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -655,10 +596,7 @@
         changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -693,10 +631,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -736,10 +671,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -779,10 +711,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -811,10 +740,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(2)
             .charOffset(2)
             .toLine(3)
@@ -845,10 +771,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(1)
             .charOffset(2)
             .toLine(2)
@@ -879,10 +802,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(1)
             .charOffset(2)
             .toLine(3)
@@ -913,10 +833,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(1)
             .charOffset(2)
             .toLine(3)
@@ -945,10 +862,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(3)
@@ -978,10 +892,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(2)
             .charOffset(2)
             .toLine(4)
@@ -1010,10 +921,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -1050,10 +958,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(2)
             .charOffset(2)
             .toLine(4)
@@ -1077,10 +982,7 @@
         changeOps.change(changeId).newPatchset().file("myFile").delete().create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -1109,14 +1011,7 @@
             .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(3)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -1137,14 +1032,7 @@
             .content("Line 2\nLine 3\nLine 4\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(3)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -1160,14 +1048,7 @@
     PatchSet.Id patchset2Id =
         changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(3)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -1191,14 +1072,7 @@
     PatchSet.Id patchset3Id =
         changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(3)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset3Id);
 
@@ -1228,14 +1102,7 @@
             .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(3)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -1262,14 +1129,7 @@
             .content("Line 1\nLine two\nLine three\nLine 4\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(2)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -1292,14 +1152,7 @@
             .content("Line 1\nLine 2\nLine three\nLine 4\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(2)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -1320,14 +1173,7 @@
             .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(3)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -1348,14 +1194,7 @@
             .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(4)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -1376,14 +1215,7 @@
             .content("Line 1\nLine 2\nLine three\nLine 4\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(4)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -1399,14 +1231,7 @@
     PatchSet.Id patchset2Id =
         changeOps.change(changeId).newPatchset().file("myFile").delete().create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onLine(3)
-            .ofFile("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
     assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
@@ -1429,13 +1254,7 @@
             .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
             .create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onFileLevelOf("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
 
     CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
 
@@ -1451,13 +1270,7 @@
     PatchSet.Id patchset2Id =
         changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onFileLevelOf("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -1482,7 +1295,7 @@
             .content("Line 1\nLine 2\nLine 3\nLine 4\n")
             .create();
     // Add comment.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().onFileLevelOf("myFile").create();
+    newComment(patchset1Id).onFileLevelOf("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -1500,13 +1313,7 @@
     PatchSet.Id patchset2Id =
         changeOps.change(changeId).newPatchset().file("myFile").delete().create();
     // Add comment.
-    String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
-            .onFileLevelOf("myFile")
-            .create();
+    String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
     assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
@@ -1529,7 +1336,7 @@
             .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
             .create();
     // Add comment.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().onPatchsetLevel().create();
+    newComment(patchset1Id).onPatchsetLevel().create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -1545,7 +1352,7 @@
     PatchSet.Id patchset2Id =
         changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
     // Add comment.
-    changeOps.change(changeId).patchset(patchset1Id).newComment().onPatchsetLevel().create();
+    newComment(patchset1Id).onPatchsetLevel().create();
 
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
 
@@ -1553,7 +1360,7 @@
   }
 
   @Test
-  public void lineCommentOnCommitMessageIsPortedToNewPosition() throws Exception {
+  public void commentOnCommitMessageIsPortedToNewPosition() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId =
         changeOps.newChange().commitMessage("Summary line\n\nText 1\nText 2").create();
@@ -1566,10 +1373,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        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)
@@ -1581,6 +1385,190 @@
     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);
+  }
+
+  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);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 27b866b..1ad952c 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -14,9 +14,11 @@
 
 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;
@@ -32,8 +34,11 @@
 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;
@@ -67,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";
@@ -320,6 +326,58 @@
   }
 
   @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);
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 9638658..1a2ae7c 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -82,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);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 0365f9f..ed4c33a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -22,14 +22,19 @@
 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.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.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -39,12 +44,17 @@
 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;
@@ -56,9 +66,13 @@
 @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 {
@@ -165,7 +179,8 @@
     assertThat(emailBody)
         .contains(
             user.fullName()
-                + " removed themselves from the attention set of this change.\n The reason is: removed.");
+                + " removed themselves from the attention set of this change.\n"
+                + " The reason is: removed.");
   }
 
   @Test
@@ -318,7 +333,32 @@
     attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
-    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was removed");
+    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
@@ -337,7 +377,7 @@
     attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
-    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was removed");
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
   }
 
   @Test
@@ -406,7 +446,7 @@
     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 was removed");
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
   }
 
   @Test
@@ -457,7 +497,7 @@
         Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
-    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was removed");
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
   }
 
   @Test
@@ -586,7 +626,8 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same time");
+            "user can not be added/removed twice, and can not be added and removed at the same"
+                + " time");
   }
 
   @Test
@@ -602,7 +643,8 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same time");
+            "user can not be added/removed twice, and can not be added and removed at the same"
+                + " time");
   }
 
   @Test
@@ -638,7 +680,8 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same time");
+            "user can not be added/removed twice, and can not be added and removed at the same"
+                + " time");
   }
 
   @Test
@@ -933,6 +976,64 @@
   }
 
   @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());
@@ -993,7 +1094,7 @@
         Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
     assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
-    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was removed");
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
   }
 
   @Test
@@ -1256,11 +1357,20 @@
 
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
-    return r.getChange().attentionSet().stream()
-        .filter(a -> a.account().get() == account.id().get())
+    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);
   }
@@ -1276,4 +1386,8 @@
     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/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index d8d1d54..4f61d79 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -14,10 +14,12 @@
 
 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;
@@ -51,13 +53,11 @@
 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.RobotCommentInfo;
 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;
@@ -66,7 +66,6 @@
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.sql.Timestamp;
@@ -98,8 +97,6 @@
   @Inject private ChangeOperations changeOperations;
   @Inject private AccountOperations accountOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private CommentsUtil commentsUtil;
-  @Inject private TestCommentHelper testCommentHelper;
 
   private final Integer[] lines = {0, 1};
 
@@ -658,6 +655,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();
@@ -702,6 +727,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();
@@ -715,22 +750,62 @@
   }
 
   @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
@@ -1103,12 +1178,6 @@
                 + "\n"
                 + "comments\n"
                 + "\n"
-                + url
-                + "c/"
-                + project.get()
-                + "/+/"
-                + c
-                + "/1/a.txt \n"
                 + "File a.txt:\n"
                 + "\n"
                 + url
@@ -1116,7 +1185,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"
@@ -1126,17 +1197,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
@@ -1144,7 +1211,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"
@@ -1154,7 +1223,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"
@@ -1164,7 +1235,9 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/2/a.txt@1 \n"
+                + "/comment/"
+                + ps2List.get(2).id
+                + " \n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
                 + "\n"
@@ -1174,7 +1247,9 @@
                 + project.get()
                 + "/+/"
                 + c
-                + "/2/a.txt@2 \n"
+                + "/comment/"
+                + ps2List.get(3).id
+                + " \n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
@@ -1474,26 +1549,73 @@
 
   @Test
   public void canCreateHumanCommentWithRobotCommentAsParentAndUnsetUnresolved() throws Exception {
-    PushOneCommit.Result result = createChange();
-    String changeId = result.getChangeId();
-    String ps1 = result.getCommit().name();
+    Change.Id changeId = changeOperations.newChange().create();
+    String parentRobotCommentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().create();
 
-    testCommentHelper.addRobotComment(
-        result.getChangeId(),
-        TestCommentHelper.createRobotCommentInputWithMandatoryFields(FILE_NAME));
-    RobotCommentInfo robotCommentInfo =
-        Iterables.getOnlyElement(gApi.changes().id(changeId).current().robotCommentsAsList());
+    CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+    createdCommentInput.inReplyTo = parentRobotCommentUuid;
+    createdCommentInput.unresolved = null;
+    addComments(changeId, createdCommentInput);
 
-    CommentInput comment = newComment(FILE_NAME, "comment 1 reply");
-    comment.inReplyTo = robotCommentInfo.id;
-    comment.unresolved = null;
-    addComments(changeId, ps1, comment);
+    CommentInfo resultNewComment =
+        Iterables.getOnlyElement(
+            getPublishedCommentsAsList(changeId).stream()
+                .filter(c -> c.message.equals("comment reply"))
+                .collect(toImmutableSet()));
 
-    CommentInfo resultComment = Iterables.getOnlyElement(getPublishedCommentsAsList(changeId));
-    assertThat(resultComment.inReplyTo).isEqualTo(robotCommentInfo.id);
+    assertThat(resultNewComment.inReplyTo).isEqualTo(parentRobotCommentUuid);
 
     // Default unresolved is false.
-    assertThat(resultComment.unresolved).isFalse();
+    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 {
@@ -1510,6 +1632,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();
@@ -1610,11 +1738,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();
   }
@@ -1633,6 +1769,10 @@
     return gApi.changes().id(changeId).commentsAsList();
   }
 
+  private List<CommentInfo> getPublishedCommentsAsList(Change.Id changeId) throws Exception {
+    return gApi.changes().id(changeId.get()).commentsAsList();
+  }
+
   private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
       throws Exception {
     return gApi.changes().id(changeId).revision(revId).drafts();
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index e293edf..0bd6554 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -25,6 +25,7 @@
 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;
@@ -43,6 +44,7 @@
 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;
@@ -154,7 +156,417 @@
         .parents()
         .onlyElement()
         .commit()
-        .isEqualTo(parentCommitId.getName());
+        .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
@@ -626,6 +1038,105 @@
   }
 
   @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();
@@ -664,6 +1175,39 @@
   }
 
   @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();
@@ -701,6 +1245,36 @@
     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();
   }
@@ -721,4 +1295,8 @@
       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
index cf687aa..c29cf99 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
@@ -14,8 +14,11 @@
 
 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;
@@ -27,9 +30,16 @@
 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;
 
@@ -44,7 +54,6 @@
     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);
   }
@@ -313,6 +322,59 @@
   }
 
   @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();
 
@@ -624,6 +686,59 @@
   }
 
   @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();
 
@@ -643,10 +758,393 @@
     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 robotCommentCanBeCreatedAsResolved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().resolved().create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).unresolved().isFalse();
+  }
+
+  @Test
+  public void robotCommentCanBeCreatedAsUnresolved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    String commentUuid =
+        changeOperations.change(changeId).currentPatchset().newRobotComment().unresolved().create();
+
+    CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+    assertThat(comment).unresolved().isTrue();
+  }
+
+  @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()).commentsAsList();
   }
 
+  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();
   }
@@ -662,6 +1160,33 @@
                     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()
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 60bf64c..1a43269 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -41,7 +41,7 @@
       case V6_7:
         return "blacktop/elasticsearch:6.7.2";
       case V6_8:
-        return "blacktop/elasticsearch:6.8.11";
+        return "blacktop/elasticsearch:6.8.12";
       case V7_0:
         return "blacktop/elasticsearch:7.0.1";
       case V7_1:
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index 9296a6b..b9d5313 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -72,15 +72,15 @@
   public void atLeastMinorVersion() throws Exception {
     assertThat(ElasticVersion.V6_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isTrue();
     assertThat(ElasticVersion.V6_8.isAtLeastMinorVersion(ElasticVersion.V6_8)).isTrue();
-    assertThat(ElasticVersion.V7_0.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_1.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_2.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_3.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_4.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_5.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_7.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
-    assertThat(ElasticVersion.V7_8.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
+    assertThat(ElasticVersion.V7_0.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_1.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_2.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_3.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_4.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_5.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_6.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_7.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
+    assertThat(ElasticVersion.V7_8.isAtLeastMinorVersion(ElasticVersion.V6_8)).isFalse();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index d19073d..5d420d3 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -21,12 +21,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/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/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
new file mode 100644
index 0000000..9c30fc9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -0,0 +1,227 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.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.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 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;
+
+  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);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenThrow(PatchListNotAvailableException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
+
+    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);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenThrow(IllegalStateException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
+
+    assertThat(portedComments).isNotEmpty();
+  }
+
+  @Test
+  public void commentsAreNotDroppedWhenRetrievingCommitSha1sHasUnexpectedError() 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);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(patchListCache.getOldId(any(), any(), any())).thenThrow(IllegalStateException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
+
+    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);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenThrow(IllegalStateException.class);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
+
+    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);
+    // 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(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    PatchList emptyDiff =
+        new PatchList(
+            dummyObjectId,
+            dummyObjectId,
+            false,
+            ComparisonType.againstOtherPatchSet(),
+            new PatchListEntry[0]);
+    // 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));
+
+    // 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");
+  }
+
+  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");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index daefd7c..5cefe74 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -32,6 +32,8 @@
 
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
+
+  @SuppressWarnings("TruthIncompatibleType")
   @Test
   public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
     /* Comments should not be linked to Gerrit's autogenerated messages */
@@ -54,7 +56,9 @@
 
     // Make sure no comment is linked to the auto-gen message
     assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
-        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit"));
+        .doesNotContain(
+            /* expected: String, actual: ChangeMessage */ getChangeMessage(
+                changeMessages, "cmAutoGenByGerrit"));
   }
 
   @Test
diff --git a/plugins/delete-project b/plugins/delete-project
index 516fbd8..2dc456a 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 516fbd8aebfcc49b278b0eb985add293d753bb3f
+Subproject commit 2dc456a6891f1bc55a9d637cf2553f27ceae6c49
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 783f5c6..d6a3381 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 783f5c65c7dca522658efe10d57d1ac9ab5f9007
+Subproject commit d6a33818440eb20aca64a761f79652525b3eb060
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index 9affc1b..d861681 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -130,8 +130,8 @@
       }
 
       return getAdminLinks(this._account,
-          this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
-          this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
+          params => this.$.restAPI.getAccountCapabilities(params),
+          () => this.$.jsAPI.getAdminMenuLinks(),
           options)
           .then(res => {
             this._filteredLinks = res.links;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index 66f7586..aee5a14 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -90,7 +90,7 @@
 
   constructor() {
     super();
-    this._query = this._getRepoBranchesSuggestions.bind(this);
+    this._query = (input: string) => this._getRepoBranchesSuggestions(input);
   }
 
   /** @override */
@@ -132,8 +132,10 @@
     this.canCreate = !!branch && !!subject;
   }
 
-  handleCreateChange() {
-    if (!this.repoName || !this.branch || !this.subject) return;
+  handleCreateChange(): Promise<void> {
+    if (!this.repoName || !this.branch || !this.subject) {
+      return Promise.resolve();
+    }
     const isPrivate = this.$.privateChangeCheckBox.checked;
     const isWip = true;
     return this.$.restAPI
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 259a302..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.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 '../../../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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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 {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
-
-/**
- * @extends PolymerElement
- */
-class GrGroupAuditLog extends ListViewMixin(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-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
deleted file mode 100644
index dff279e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ /dev/null
@@ -1,310 +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 '../../../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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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 {getBaseUrl} from '../../../utils/url-util.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 PolymerElement
- */
-class GrGroupMembers extends 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;
-          }));
-
-          promises.push(this.$.restAPI.getIsGroupOwner(config.name)
-              .then(isOwner => {
-                this._groupOwner = !!isOwner;
-              }));
-
-          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 getBaseUrl() + url.slice(1);
-    }
-    return getBaseUrl() + url;
-  }
-
-  _handleSavingGroupMember() {
-    return this.$.restAPI.saveGroupMember(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.deleteGroupMember(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, ' '), (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(err.statusText);
-          }
-          throw err;
-        })
-        .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..f8f1fee
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -0,0 +1,396 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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,
+} 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?: GroupId;
+
+  @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 as GroupId;
+
+      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_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index 03fccde..b5d2217 100644
--- 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
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-members.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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');
@@ -210,6 +210,7 @@
 
   test('add included group 404 shows helpful error text', () => {
     element._groupOwner = true;
+    element._groupName = 'test';
 
     const memberName = 'bad-name';
     const alertStub = sinon.stub();
@@ -224,9 +225,9 @@
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
 
-    return element._handleSavingIncludedGroups().then(() => {
+    return flush(element._handleSavingIncludedGroups().then(() => {
       assert.isTrue(alertStub.called);
-    });
+    }));
   });
 
   test('add included group network-error throws an exception', async () => {
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 4ab1b98..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ /dev/null
@@ -1,158 +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 '../../../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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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 PolymerElement
- */
-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,
-      // states
-      _creatingChange: Boolean,
-      _editingConfig: Boolean,
-      _runningGC: 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() {
-    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();
-  }
-
-  _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;
-        });
-  }
-}
-
-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_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
index 0bb0c55..56e9507 100644
--- 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
@@ -28,10 +28,11 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    repoStub = sinon.stub(
-        element.$.restAPI,
-        'getProjectConfig')
-        .callsFake(() => Promise.resolve({}));
+    // 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', () => {
@@ -72,6 +73,7 @@
       sinon.stub(GerritNav, 'navigateToRelativeUrl');
       handleSpy = sinon.spy(element, '_handleEditRepoConfig');
       alertStub = sinon.stub();
+      element.repo = 'test';
       element.addEventListener('show-alert', alertStub);
     });
 
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 673332b..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ /dev/null
@@ -1,321 +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 '../../../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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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 {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getDisplayName} from '../../../utils/display-name-util.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {truncatePath} from '../../../utils/path-list-util.js';
-import {changeStatuses} from '../../../utils/change-util.js';
-import {isServiceUser} from '../../../utils/account-util.js';
-
-const CHANGE_SIZE = {
-  XS: 10,
-  SMALL: 50,
-  MEDIUM: 250,
-  LARGE: 1000,
-};
-
-// How many reviewers should be shown with an account-label?
-const PRIMARY_REVIEWERS_COUNT = 2;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeListItem extends ChangeTableMixin(GestureEventListeners(
-    LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-list-item'; }
-
-  static get properties() {
-    return {
-      /** The logged-in user's account, or null if no user is logged in. */
-      account: {
-        type: Object,
-        value: null,
-      },
-      visibleChangeTableColumns: Array,
-      labelNames: {
-        type: Array,
-      },
-
-      /** @type {?} */
-      change: Object,
-      config: Object,
-      /** Name of the section in the change-list. Used for reporting. */
-      sectionName: String,
-      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,
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
-              'change-list-item-cell');
-        });
-  }
-
-  _changeStatuses(change) {
-    return changeStatuses(change);
-  }
-
-  _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 ? 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 `added ${change.insertions}, removed ${change.deletions} lines`;
-    }
-  }
-
-  _hasAttention(account) {
-    if (!this.change || !this.change.attention_set) return false;
-    return this.change.attention_set.hasOwnProperty(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) {
-    if (!change || !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) {
-    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
-  }
-
-  _computeAdditionalReviewers(change) {
-    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
-  }
-
-  _computeAdditionalReviewersCount(change) {
-    return this._computeAdditionalReviewers(change).length;
-  }
-
-  _computeAdditionalReviewersTitle(change, config) {
-    if (!change || !config) return '';
-    return this._computeAdditionalReviewers(change)
-        .map(user => getDisplayName(config, user, true))
-        .join(', ');
-  }
-
-  _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},
-    }));
-  }
-
-  _handleChangeClick(e) {
-    // 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,
-    });
-  }
-}
-
-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..5d898bd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -0,0 +1,366 @@
+/**
+ * @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,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+enum CHANGE_SIZE {
+  XS = 10,
+  SMALL = 50,
+  MEDIUM = 250,
+  LARGE = 1000,
+}
+
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
+@customElement('gr-change-list-item')
+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];
+    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: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    // Mimic a Set.
+    // TODO(TS): replace with `u_green` to remove the quotes and brackets
+    const classes: {
+      cell: boolean;
+      label: boolean;
+      ['u-green']?: boolean;
+      ['u-monospace']?: boolean;
+      ['u-red']?: boolean;
+      ['u-gray-background']?: boolean;
+    } = {
+      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: ChangeInfo | undefined, labelName: string) {
+    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+    if (!label) {
+      return '';
+    }
+    if (label.approved) {
+      return '✓';
+    }
+    if (label.rejected) {
+      return '✕';
+    }
+    if (label.value && label.value > 0) {
+      return `+${label.value}`;
+    }
+    if (label.value && label.value < 0) {
+      return label.value;
+    }
+    return '';
+  }
+
+  _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) 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 < 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},
+      })
+    );
+  }
+
+  _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,
+    });
+  }
+}
+
+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_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
index 6d51310..1970928 100644
--- 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
@@ -278,7 +278,7 @@
     assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
         [change.project, true, change.internalHost]);
     assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
-        [change.branch, change.project, null, change.internalHost]);
+        [change.branch, change.project, undefined, change.internalHost]);
     assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
         [change.topic, change.internalHost]);
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index dbfb6be..0658e16 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -66,7 +66,7 @@
       createChangeTap: {
         type: Function,
         value() {
-          return this._createChangeTap.bind(this);
+          return e => this._createChangeTap(e);
         },
       },
 
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 7901c53..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.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 '../../../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 PolymerElement
- */
-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..25369b8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -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 '../../../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';
+
+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] : '';
+  }
+
+  _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.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 72bdca6..136835d 100644
--- 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
@@ -37,7 +37,7 @@
       [[_computeDetail(_accountDetails, 'name')]]
     </h1>
     <hr />
-    <div class$="status [[_computeStatusClass(_accountDetails)]]">
+    <div class$="status [[_computeStatusClass(_status)]]">
       <span>Status:</span> [[_status]]
     </div>
     <div>
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
index 6baacef..15fbf8b 100644
--- 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
@@ -32,10 +32,9 @@
         .returns(Promise.resolve({
           name: 'foo',
           email: 'bar',
+          status: 'OOO',
           registered_on: '2015-03-12 18:32:08.000000000',
         }));
-    sinon.stub(element.$.restAPI, 'getAccountStatus')
-        .returns(Promise.resolve('baz'));
 
     element.userId = 'foo.bar@baz';
     flush(() => {
@@ -46,7 +45,7 @@
       flush(() => {
         flushAsynchronousOperations();
         assert.isNull(element._accountDetails);
-        assert.isNull(element._status);
+        assert.equal(element._status, '');
 
         done();
       });
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 0285f26..fba4b53 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -1295,10 +1295,10 @@
     if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
       this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
         buttonKey);
-      return function() {
+      return () => {
         this._actionLoadingMessage = '';
         this._disabledMenuActions = [];
-      }.bind(this);
+      };
     }
 
     // Otherwise it's a top-level action.
@@ -1306,11 +1306,11 @@
         .querySelector(`[data-action-key="${buttonKey}"]`);
     buttonEl.setAttribute('loading', true);
     buttonEl.disabled = true;
-    return function() {
+    return () => {
       this._actionLoadingMessage = '';
       buttonEl.removeAttribute('loading');
       buttonEl.disabled = false;
-    }.bind(this);
+    };
   }
 
   /**
@@ -1324,7 +1324,7 @@
         this._setLoadingOnButtonWithKey(action.__type, action.__key);
 
     this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
-        action).then(this._handleResponse.bind(this, action));
+        action).then(res => this._handleResponse(action, res));
   }
 
   _showActionDialog(dialog) {
@@ -1570,7 +1570,7 @@
 
     return revisionActionValues
         .concat(changeActionValues)
-        .sort(this._actionComparator.bind(this))
+        .sort((a, b) => this._actionComparator(a, b))
         .map(action => {
           if (ACTIONS_WITH_ICONS.has(action.__key)) {
             action.icon = action.__key;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 2174791..ce08a86 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -512,20 +512,20 @@
         })
         .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('comment-save', this._handleCommentSave.bind(this));
-    this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
+    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-refresh', e => this._reloadDrafts(e));
     this.addEventListener('comment-discard',
-        this._handleCommentDiscard.bind(this));
+        e => this._handleCommentDiscard(e));
     this.addEventListener('change-message-deleted',
         () => this._reload());
     this.addEventListener('editable-content-save',
-        this._handleCommitMessageSave.bind(this));
+        e => this._handleCommitMessageSave(e));
     this.addEventListener('editable-content-cancel',
-        this._handleCommitMessageCancel.bind(this));
+        e => this._handleCommitMessageCancel(e));
     this.addEventListener('open-fix-preview',
-        this._onOpenFixPreview.bind(this));
+        e => this._onOpenFixPreview(e));
     this.addEventListener('close-fix-preview',
-        this._onCloseFixPreview.bind(this));
+        e => this._onCloseFixPreview(e));
     this.listen(window, 'scroll', '_handleScroll');
     this.listen(document, 'visibilitychange', '_handleVisibilityChange');
 
@@ -1183,7 +1183,7 @@
 
   _maybeShowRevertDialog() {
     getPluginLoader().awaitPluginsLoaded()
-        .then(this._getLoggedIn.bind(this))
+        .then(() => this._getLoggedIn())
         .then(loggedIn => {
           if (!loggedIn || !this._change ||
               this._change.status !== ChangeStatus.MERGED) {
@@ -1663,7 +1663,7 @@
 
   _getChangeDetail() {
     const detailCompletes = this.$.restAPI.getChangeDetail(
-        this._changeNum, this._handleGetChangeDetailError.bind(this));
+        this._changeNum, r => this._handleGetChangeDetailError(r));
     const editCompletes = this._getEdit();
     const prefCompletes = this._getPreferences();
 
@@ -2138,7 +2138,8 @@
     }
 
     this._updateCheckTimerHandle = this.async(() => {
-      fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
+      const change = this._change;
+      fetchChangeUpdates(change, this.$.restAPI).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
@@ -2152,7 +2153,12 @@
           toastMessage = ReloadToastMessage.NEW_MESSAGE;
         }
 
-        if (!toastMessage) {
+        // 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;
         }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index b311efc..f0b8372 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -753,6 +753,7 @@
     class="scrollable"
     no-cancel-on-outside-click=""
     no-cancel-on-esc-key=""
+    scroll-action="lock"
     with-backdrop=""
   >
     <gr-reply-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index 3d530b5..179caaa 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -1971,7 +1971,7 @@
         assert.isFalse(getChangeDetailStub.called);
       });
 
-      test('_startUpdateCheckTimer up-to-date', () => {
+      test('_startUpdateCheckTimer up-to-date', async () => {
         const getChangeDetailStub =
             sinon.stub(element.$.restAPI, 'getChangeDetail')
                 .callsFake(() => Promise.resolve(generateChange({
@@ -1981,8 +1981,9 @@
                 })));
 
         element._serverConfig = {change: {update_delay: 12345}};
+        await flush();
 
-        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.equal(element._startUpdateCheckTimer.callCount, 2);
         assert.isTrue(getChangeDetailStub.called);
         assert.equal(element.async.lastCall.args[1], 12345 * 1000);
       });
@@ -2001,6 +2002,24 @@
           done();
         });
         element._serverConfig = {change: {update_delay: 12345}};
+
+        assert.equal(element._startUpdateCheckTimer.callCount, 1);
+      });
+
+      test('_startUpdateCheckTimer respects _loading', async () => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail')
+            .callsFake(() => Promise.resolve(generateChange({
+              // new patchset was uploaded
+              revisionsCount: 2,
+              messagesCount: 1,
+            })));
+
+        element._loading = true;
+        element._serverConfig = {change: {update_delay: 12345}};
+        await flush();
+
+        // No toast, instead a second call to _startUpdateCheckTimer().
+        assert.equal(element._startUpdateCheckTimer.callCount, 2);
       });
 
       test('_startUpdateCheckTimer new status shows an alert', done => {
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 9489b94..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ /dev/null
@@ -1,211 +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 '../../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 PolymerElement
- */
-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..beaf0f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -0,0 +1,248 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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
+const REVERT_TYPES = {
+  REVERT_SINGLE_CHANGE: 1,
+  REVERT_SUBMISSION: 2,
+};
+
+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 = REVERT_TYPES.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 === REVERT_TYPES.REVERT_SINGLE_CHANGE;
+  }
+
+  _computeIfRevertSubmission(revertType: number) {
+    return revertType === REVERT_TYPES.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 = REVERT_TYPES.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 = REVERT_TYPES.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[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;
+    if (this._message)
+      this._revertMessages[REVERT_TYPES.REVERT_SINGLE_CHANGE] = this._message;
+    this._message = this._revertMessages[REVERT_TYPES.REVERT_SUBMISSION];
+  }
+
+  _handleConfirmTap(e: MouseEvent) {
+    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: 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-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 42afcc2..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
+++ /dev/null
@@ -1,92 +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-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 PolymerElement */
-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-file-list-constants.ts b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
index 7c07e68..0e55494 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
@@ -15,10 +15,8 @@
  * limitations under the License.
  */
 
-export const GrFileListConstants = {
-  FilesExpandedState: {
-    ALL: 'all',
-    NONE: 'none',
-    SOME: 'some',
-  },
-};
+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 05f2ab0..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ /dev/null
@@ -1,302 +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 '../../../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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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 {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {
-  computeLatestPatchNum,
-  getRevisionByPatchNum,
-  patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-const MERGED_STATUS = 'MERGED';
-
-/**
- * @extends PolymerElement
- */
-class GrFileListHeader extends KeyboardShortcutMixin(
-    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].includes(undefined)) {
-      return undefined;
-    }
-
-    return !(loggedIn && (account._account_id === change.owner._account_id));
-  }
-
-  _computePatchSetDescription(change, patchNum) {
-    // Polymer 2: check for undefined
-    if ([change, patchNum].includes(undefined)) {
-      return;
-    }
-
-    const rev = 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 = 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 (patchNumEquals(basePatchNum, this.basePatchNum) &&
-        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 = computeLatestPatchNum(allPatchSets);
-    if (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..fb91806
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -0,0 +1,403 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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,
+} 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 {ChangeNum} from '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+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?: ChangeNum;
+
+  @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 {
+    // Polymer 2: check for undefined
+    if (
+      this.loggedIn === undefined ||
+      this.change === undefined ||
+      this.account === undefined
+    ) {
+      return false;
+    }
+
+    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_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index dd05b9d..af59616 100644
--- 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
@@ -17,9 +17,10 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-file-list-header.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {generateChange} from '../../../test/test-utils.js';
+import 'lodash/lodash.js';
 
 const basicFixture = fixtureFromElement('gr-file-list-header');
 
@@ -61,12 +62,20 @@
   });
 
   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);
+    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', () => {
@@ -96,11 +105,13 @@
       owner: {_account_id: 1},
     };
     element.account = {_account_id: 1};
+    element.owner = {_account_id: 1};
     element.loggedIn = true;
 
     flushAsynchronousOperations();
 
     // 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');
@@ -182,13 +193,13 @@
     const actions = element.shadowRoot
         .querySelector('.fileViewActions');
     assert.equal(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    element.filesExpanded = FilesExpandedState.SOME;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    element.filesExpanded = FilesExpandedState.ALL;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    element.filesExpanded = FilesExpandedState.NONE;
     flushAsynchronousOperations();
     assert.equal(getComputedStyle(actions).display, 'none');
   });
@@ -200,15 +211,15 @@
     const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    element.filesExpanded = FilesExpandedState.SOME;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    element.filesExpanded = FilesExpandedState.ALL;
     flushAsynchronousOperations();
     assert.equal(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    element.filesExpanded = FilesExpandedState.NONE;
     flushAsynchronousOperations();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index aaf0205..bf45eb6 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -34,7 +34,7 @@
 import {htmlTemplate} from './gr-file-list_html.js';
 import {asyncForeach} from '../../../utils/async-util.js';
 import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {FilesExpandedState} 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 {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
@@ -42,7 +42,6 @@
 import {appContext} from '../../../services/app-context.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {descendedFromClass} from '../../../utils/dom-util.js';
-import {getRevisionByPatchNum} from '../../../utils/patch-set-util.js';
 import {
   addUnmodifiedFiles,
   computeDisplayPath,
@@ -51,8 +50,6 @@
   specialFilePathCompare,
 } from '../../../utils/path-list-util.js';
 
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
 const WARN_SHOW_ALL_THRESHOLD = 1000;
 const LOADING_DEBOUNCE_INTERVAL = 100;
 
@@ -139,7 +136,7 @@
       },
       filesExpanded: {
         type: String,
-        value: GrFileListConstants.FilesExpandedState.NONE,
+        value: FilesExpandedState.NONE,
         notify: true,
       },
       _filesByPath: Object,
@@ -1161,17 +1158,6 @@
     this.numFilesShown = this._files.length;
   }
 
-  _computePatchSetDescription(revisions, patchNum) {
-    // Polymer 2: check for undefined
-    if ([revisions, patchNum].includes(undefined)) {
-      return '';
-    }
-
-    const rev = 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.
@@ -1210,11 +1196,11 @@
 
   _computeExpandedFiles(expandedCount, totalCount) {
     if (expandedCount === 0) {
-      return GrFileListConstants.FilesExpandedState.NONE;
+      return FilesExpandedState.NONE;
     } else if (expandedCount === totalCount) {
-      return GrFileListConstants.FilesExpandedState.ALL;
+      return FilesExpandedState.ALL;
     }
-    return GrFileListConstants.FilesExpandedState.SOME;
+    return FilesExpandedState.SOME;
   }
 
   /**
@@ -1575,7 +1561,7 @@
    * @return {boolean}
    */
   _noDiffsExpanded() {
-    return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
+    return this.filesExpanded === FilesExpandedState.NONE;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index aaa4499..0343b39 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -21,7 +21,7 @@
 import './gr-file-list.js';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrFileListConstants} from '../gr-file-list-constants.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';
@@ -1107,23 +1107,23 @@
       };
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
+          FilesExpandedState.NONE);
       element.push('_expandedFiles', {path: 'baz.bar'});
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.SOME);
+          FilesExpandedState.SOME);
       element.push('_expandedFiles', {path: 'foo.bar'});
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
+          FilesExpandedState.ALL);
       element.collapseAllDiffs();
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
+          FilesExpandedState.NONE);
       element.expandAllDiffs();
       flushAsynchronousOperations();
       assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
+          FilesExpandedState.ALL);
     });
 
     test('_renderInOrder', done => {
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 0da687f..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ /dev/null
@@ -1,430 +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 '@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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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';
-import {SpecialFilePath} from '../../../constants/constants.js';
-
-const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
-
-/**
- * @extends PolymerElement
- */
-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 {
-      /** @type {?} */
-      change: Object,
-      changeNum: Number,
-      /** @type {?} */
-      message: Object,
-      author: {
-        type: Object,
-        computed: '_computeAuthor(message)',
-      },
-      /**
-       * TODO(taoalpha): remove once the change log experiment is launched
-       *
-       * @type {Object} - a map on file and comments on it
-       */
-      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,' +
-            ' message.commentThreads)',
-      },
-      _commentCountText: {
-        type: Number,
-        computed: '_computeCommentCountText(message.commentThreads.length)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
-      _isDeletingChangeMsg: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_updateExpandedClass(message.expanded)',
-    ];
-  }
-
-  constructor() {
-    super();
-  }
-
-  /** @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(threadsLength) {
-    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, tag) {
-    return this._computeMessageContent(content, tag, true);
-  }
-
-  _patchsetCommentSummary(commentThreads) {
-    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, tag, commentThreads) {
-    const summary =
-      this._computeMessageContent(content, tag, false);
-    if (summary || !commentThreads) return summary;
-    return this._patchsetCommentSummary(commentThreads);
-  }
-
-  _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].includes(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..2b5d94d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -0,0 +1,493 @@
+/**
+ * @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,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
+const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-message': GrMessage;
+  }
+}
+
+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?: number;
+
+  @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();
+    this.dispatchEvent(
+      new CustomEvent('message-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail: {id: this.message?.id},
+      })
+    );
+  }
+
+  _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_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
index c617d2c..66b8ed2 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -80,6 +80,7 @@
     });
 
     test('delete change message', done => {
+      element.changeNum = 314159;
       element.message = {
         id: '47c43261_55aa2c41',
         author: {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index d7a41f1..630681c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -540,7 +540,7 @@
     if (reviewer.account) {
       reviewerId = reviewer.account._account_id || reviewer.account.email;
     } else if (reviewer.group) {
-      reviewerId = reviewer.group.id;
+      reviewerId = decodeURIComponent(reviewer.group.id);
       confirmed = reviewer.group.confirmed;
     }
     return {reviewer: reviewerId, confirmed};
@@ -616,7 +616,7 @@
 
     this.disabled = true;
 
-    const errFn = this._handle400Error.bind(this);
+    const errFn = r => this._handle400Error(r);
     return this._saveReview(reviewInput, errFn)
         .then(response => {
           if (!response) {
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 174bbbb..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ /dev/null
@@ -1,303 +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 '../../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';
-import {
-  hasAttention,
-  isServiceUser,
-} from '../../../utils/account-util.js';
-
-/**
- * @extends PolymerElement
- */
-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].includes(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)
-        // 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;
-    }
-  }
-
-  _computeHiddenCount(reviewers, displayedReviewers) {
-    // Polymer 2: check for undefined
-    if ([reviewers, displayedReviewers].includes(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..70e7ba7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -0,0 +1,339 @@
+/**
+ * @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 {hasAttention, isServiceUser} from '../../../utils/account-util';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+  ChangeInfo,
+  ServerInfo,
+  LabelNameToValueMap,
+  AccountInfo,
+  ApprovalInfo,
+  Reviewers,
+  AccountId,
+  DetailedLabelInfo,
+} 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';
+
+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 => parseInt(v, 10)),
+      };
+    });
+  }
+
+  /**
+   * 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) {
+    if (
+      !mutable ||
+      this.change === undefined ||
+      this.change.removable_reviewers === undefined
+    ) {
+      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: Event) {
+    e.preventDefault();
+    const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
+    if (!target.account || !this.change) {
+      return;
+    }
+    const accountID = target.account._account_id;
+    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: {[type: string]: AccountInfo[] | undefined} = 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) {
+              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): 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_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index 521945f..834cc43 100644
--- 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
@@ -163,21 +163,24 @@
     element.reviewersOnly = false;
     element._handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {}});
+    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}});
+        {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}});
+        {value: {ccsOnly: true, reviewersOnly: false}});
   });
 
   test('dont show all reviewers button with 4 reviewers', () => {
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 7608137..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ /dev/null
@@ -1,132 +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 '../../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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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 {getUserName} from '../../../utils/display-name-util.js';
-
-const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrAccountDropdown extends 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].includes(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) {
-    return [
-      {text: this._accountName(account), bold: true},
-      {text: account.email ? account.email : ''},
-    ];
-  }
-
-  _handleShortcutsTap(e) {
-    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, replacements) {
-    return url.replace(
-        INTERPOLATE_URL_PATTERN,
-        (match, p1) => replacements[p1] || '');
-  }
-
-  _accountName(account) {
-    return 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-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 11996c1..bd3c052 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -335,6 +335,7 @@
     el.show(text, actionText, actionCallback);
     this._alertElement = el;
     this.fire('iron-announce', {text}, {bubbles: true});
+    this.reporting.reportInteraction('show-alert', {text});
   }
 
   _hideAlert() {
@@ -365,10 +366,8 @@
 
     this._alertElement = this._createToastAlert();
     this._alertElement.type = ErrorType.AUTH;
-    this._alertElement.show(
-      errorText,
-      actionText,
-      this._createLoginPopup.bind(this)
+    this._alertElement.show(errorText, actionText, () =>
+      this._createLoginPopup()
     );
     this.fire('iron-announce', {text: errorText}, {bubbles: true});
     this._refreshingCredentials = true;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 33e720b..4bd90ea 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -64,9 +64,9 @@
 
   constructor() {
     super();
-    this.keyboardShortcutDirectoryListener = this._onDirectoryUpdated.bind(
-      this
-    );
+    this.keyboardShortcutDirectoryListener = (
+      d?: Map<ShortcutSection, SectionView>
+    ) => this._onDirectoryUpdated(d);
   }
 
   /** @override */
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 03c7212..88c4cef 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -274,8 +274,8 @@
       this._topMenus = result[1];
 
       return getAdminLinks(account,
-          this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
-          this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
+          params => this.$.restAPI.getAccountCapabilities(params),
+          () => this.$.jsAPI.getAdminMenuLinks())
           .then(res => {
             this._adminLinks = res.links;
           });
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index b45782f..5016c40 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -758,10 +758,11 @@
     change: ChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
-    basePatchNum?: PatchSetNum
+    basePatchNum?: PatchSetNum,
+    lineNum?: number
   ) {
     this._navigate(
-      this.getUrlForDiff(change, filePath, patchNum, basePatchNum)
+      this.getUrlForDiff(change, filePath, patchNum, basePatchNum, lineNum)
     );
   },
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 6f1bc60..aa2e4ce 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -72,6 +72,7 @@
   DASHBOARD: /^\/dashboard\/(.+)$/,
   CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
   PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+  LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
 
   AGREEMENTS: /^\/settings\/agreements\/?/,
   NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
@@ -880,6 +881,11 @@
       '_handleProjectDashboardRoute'
     );
 
+    this._mapRoute(
+      RoutePattern.LEGACY_PROJECT_DASHBOARD,
+      '_handleLegacyProjectDashboardRoute'
+    );
+
     this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
 
     this._mapRoute(
@@ -1255,6 +1261,10 @@
     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]));
   }
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
index 5ee58c9..927434b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -197,6 +197,7 @@
       '_handleImproperlyEncodedPlusRoute',
       '_handlePassThroughRoute',
       '_handleProjectDashboardRoute',
+      '_handleLegacyProjectDashboardRoute',
       '_handleProjectsOldRoute',
       '_handleRepoAccessRoute',
       '_handleRepoDashboardsRoute',
@@ -617,6 +618,14 @@
       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);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index 772b412..813298c 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -44,19 +44,22 @@
       _projectSuggestions: {
         type: Function,
         value() {
-          return this._fetchProjects.bind(this);
+          return (predicate, expression) =>
+            this._fetchProjects(predicate, expression);
         },
       },
       _groupSuggestions: {
         type: Function,
         value() {
-          return this._fetchGroups.bind(this);
+          return (predicate, expression) =>
+            this._fetchGroups(predicate, expression);
         },
       },
       _accountSuggestions: {
         type: Function,
         value() {
-          return this._fetchAccounts.bind(this);
+          return (predicate, expression) =>
+            this._fetchAccounts(predicate, expression);
         },
       },
       /**
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index a2036b7..b49f522 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -541,6 +541,9 @@
     comments: T[]
   ): T[] {
     return comments.slice(0).sort((c1, c2) => {
+      const d1 = !!(c1 as HumanCommentInfoWithPath).__draft;
+      const d2 = !!(c2 as HumanCommentInfoWithPath).__draft;
+      if (d1 !== d2) return d1 ? 1 : -1;
       const dateDiff =
         parseDate(c1.updated).valueOf() - parseDate(c2.updated).valueOf();
       if (dateDiff) {
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
index 0a7c3b5..df5e450 100644
--- 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
@@ -227,10 +227,12 @@
             },
             {
               id: 12,
-              in_reply_to: 2,
+              in_reply_to: 4,
               patch_set: 2,
               line: 1,
-              updated: makeTime(3),
+              // 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': [
@@ -262,7 +264,7 @@
               patch_set: 2,
               unresolved: true,
               line: 1,
-              updated: makeTime(2),
+              updated: makeTime(3),
             },
           ],
         };
@@ -549,16 +551,16 @@
                 unresolved: true,
                 line: 1,
                 __path: 'file/one',
-                updated: '2013-02-26 15:02:43.986000000',
+                updated: '2013-02-26 15:03:43.986000000',
               },
               {
                 id: 12,
-                in_reply_to: 2,
+                in_reply_to: 4,
                 patch_set: 2,
                 line: 1,
                 __path: 'file/one',
                 __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
+                updated: '2013-02-26 15:02:43.986000000',
               },
             ],
             patchNum: 2,
@@ -708,16 +710,16 @@
             patch_set: 2,
             unresolved: true,
             line: 1,
-            updated: '2013-02-26 15:02:43.986000000',
+            updated: '2013-02-26 15:03:43.986000000',
           },
           {
             __path: 'file/one',
             __draft: true,
             id: 12,
-            in_reply_to: 2,
+            in_reply_to: 4,
             patch_set: 2,
             line: 1,
-            updated: '2013-02-26 15:03:43.986000000',
+            updated: '2013-02-26 15:02:43.986000000',
           },
         ];
         assert.deepEqual(element._changeComments.getCommentsForThread(4),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
index b7e5a0d..aee6ab9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -309,10 +309,10 @@
     return this.getContentTdByLine(line, side, row);
   }
 
-  getLineElByNumber(lineNumber: string, side?: Side) {
+  getLineElByNumber(lineNumber: string | number, side?: Side) {
     const sideSelector = side ? '.' + side : '';
     return this.diffElement.querySelector(
-      '.lineNum[data-value="' + lineNumber + '"]' + sideSelector
+      `.lineNum[data-value="${lineNumber}"]${sideSelector}`
     );
   }
 
@@ -539,10 +539,8 @@
     };
   }
 
-  setBlame(blame: BlameInfo[]) {
-    if (!this._builder || !blame) {
-      return;
-    }
+  setBlame(blame: BlameInfo[] | null) {
+    if (!this._builder) return;
     this._builder.setBlame(blame);
   }
 }
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
index 6810683..7b0caf2 100644
--- 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
@@ -41,6 +41,9 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
+    if (group.dueToMove) {
+      sectionEl.classList.add('dueToMove');
+    }
     if (group.ignoredWhitespaceOnly) {
       sectionEl.classList.add('ignoredWhitespaceOnly');
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 6760514..2eb5bf9e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -40,6 +40,9 @@
     if (group.dueToRebase) {
       sectionEl.classList.add('dueToRebase');
     }
+    if (group.dueToMove) {
+      sectionEl.classList.add('dueToMove');
+    }
     if (group.ignoredWhitespaceOnly) {
       sectionEl.classList.add('ignoredWhitespaceOnly');
     }
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
index 5240296..3eba4e1 100644
--- 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
@@ -133,6 +133,12 @@
       assert.isTrue(sectionEl.classList.contains('dueToRebase'));
     });
 
+    test('creates the section with class if dueToMove', () => {
+      group.dueToMove = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+    });
+
     test('creates first the removed and then the added rows', () => {
       const sectionEl = diffBuilder.buildSectionElement(group);
       const rowEls = sectionEl.querySelectorAll('.diff-row');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 3913df1..6412068 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -109,7 +109,11 @@
       throw Error('Invalid line length from preferences.');
     }
 
-    this._layerUpdateListener = this._handleLayerUpdate.bind(this);
+    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);
@@ -621,8 +625,9 @@
    * Set the blame information for the diff. For any already-rendered line,
    * re-render its blame cell content.
    */
-  setBlame(blame: BlameInfo[]) {
+  setBlame(blame: BlameInfo[] | null) {
     this._blameInfo = blame;
+    if (!blame) return;
 
     // TODO(wyatta): make this loop asynchronous.
     for (const commit of blame) {
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
index ebd410e..6f51793 100644
--- 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
@@ -169,7 +169,7 @@
 
   moveDown() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.next(this._rowHasSide.bind(this));
+      this.$.cursorManager.next((row: Element) => this._rowHasSide(row));
     } else {
       this.$.cursorManager.next();
     }
@@ -177,7 +177,7 @@
 
   moveUp() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.previous(this._rowHasSide.bind(this));
+      this.$.cursorManager.previous((row: Element) => this._rowHasSide(row));
     } else {
       this.$.cursorManager.previous();
     }
@@ -185,7 +185,9 @@
 
   moveToVisibleArea() {
     if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.cursorManager.moveToVisibleArea(this._rowHasSide.bind(this));
+      this.$.cursorManager.moveToVisibleArea((row: Element) =>
+        this._rowHasSide(row)
+      );
     } else {
       this.$.cursorManager.moveToVisibleArea();
     }
@@ -193,7 +195,7 @@
 
   moveToNextChunk(clipToTop?: boolean, navigateToNextFile?: boolean) {
     this.$.cursorManager.next(
-      this._isFirstRowOfChunk.bind(this),
+      (row: HTMLElement) => this._isFirstRowOfChunk(row),
       target => (target?.parentNode as HTMLElement)?.scrollHeight || 0,
       clipToTop,
       navigateToNextFile
@@ -202,17 +204,21 @@
   }
 
   moveToPreviousChunk() {
-    this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
+    this.$.cursorManager.previous((row: HTMLElement) =>
+      this._isFirstRowOfChunk(row)
+    );
     this._fixSide();
   }
 
   moveToNextCommentThread() {
-    this.$.cursorManager.next(this._rowHasThread.bind(this));
+    this.$.cursorManager.next((row: HTMLElement) => this._rowHasThread(row));
     this._fixSide();
   }
 
   moveToPreviousCommentThread() {
-    this.$.cursorManager.previous(this._rowHasThread.bind(this));
+    this.$.cursorManager.previous((row: HTMLElement) =>
+      this._rowHasThread(row)
+    );
     this._fixSide();
   }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
index c852688..08a567b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -112,7 +112,8 @@
    * because isMouseUp === true combined with an existing selection must
    * mean that this is the end of a double-click.
    */
-  handleSelectionChange(selection: Selection, isMouseUp: boolean) {
+  handleSelectionChange(selection: Selection | 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.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 5a36917..7693d56 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -331,7 +331,7 @@
     const layers = [this.$.syntaxLayer];
     // Get layers from plugins (if any).
     for (const pluginLayer of this.$.jsAPI.getDiffLayers(
-        this.path, this.changeNum, this.patchNum)) {
+        this.path, this.changeNum)) {
       layers.push(pluginLayer);
     }
     this._layers = layers;
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
index 38b0d6d..aae7808 100644
--- 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
@@ -27,7 +27,6 @@
     project-name="[[projectName]]"
     display-line="[[displayLine]]"
     is-image-diff="[[isImageDiff]]"
-    commit-range="[[commitRange]]"
     hidden$="[[hidden]]"
     no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
     line-wrapping="[[lineWrapping]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index 37d8819..cd43fdb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -979,12 +979,6 @@
     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;
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
index b521661..a85af34 100644
--- 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
@@ -359,6 +359,7 @@
     const group = new GrDiffGroup(type, lines);
     group.keyLocation = !!chunk.keyLocation;
     group.dueToRebase = !!chunk.due_to_rebase;
+    group.dueToMove = !!chunk.due_to_move;
     group.ignoredWhitespaceOnly = !!chunk.common;
     return group;
   }
@@ -676,6 +677,9 @@
       if (chunk.due_to_rebase) {
         subChunk.due_to_rebase = true;
       }
+      if (chunk.due_to_move) {
+        subChunk.due_to_move = true;
+      }
       return subChunk;
     });
   }
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
index c1c344f..3a074bb 100644
--- 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
@@ -875,6 +875,16 @@
             }
           });
 
+      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(' ');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 73ad899..8d57085 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -52,7 +52,6 @@
   isMagicPath, specialFilePathCompare,
 } from '../../../utils/path-list-util.js';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
-import {KnownExperimentId} from '../../../services/flags/flags.js';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -232,6 +231,8 @@
         type: Object,
         value: () => new Set(),
       },
+      // line number on the diff which should be scrolled to upon loading
+      _focusLineNum: Number,
     };
   }
 
@@ -308,21 +309,21 @@
   /** @override */
   attached() {
     super.attached();
-    this._isChangeCommentsLinkExperimentEnabled = this.flagsService
-        .isEnabled(KnownExperimentId.PATCHSET_CHOICE_FOR_COMMENT_LINKS);
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
 
     this.addEventListener('open-fix-preview',
-        this._onOpenFixPreview.bind(this));
+        e => this._onOpenFixPreview(e));
     this.$.cursor.push('diffs', this.$.diffHost);
-
-    const onRender = () => {
-      this.$.diffHost.removeEventListener('render', onRender);
+    this._onRenderHandler = () => {
       this.$.cursor.reInitCursor();
     };
-    this.$.diffHost.addEventListener('render', onRender);
+    this.$.diffHost.addEventListener('render', this._onRenderHandler);
+  }
+
+  detached() {
+    this.$.diffHost.removeEventListener('render', this._onRenderHandler);
   }
 
   _getLoggedIn() {
@@ -745,14 +746,12 @@
         .then(files => files.has(path));
   }
 
-  _initLineOfInterestAndCursor(lineNum, leftSide) {
+  _initLineOfInterestAndCursor(leftSide) {
     this.$.diffHost.lineOfInterest =
       this._getLineOfInterest({
-        lineNum,
         leftSide,
       });
     this._initCursor({
-      lineNum,
       leftSide,
     });
   }
@@ -819,7 +818,7 @@
   }
 
   _initPatchRange() {
-    let lineNum; let leftSide;
+    let leftSide;
     if (this.params.commentId) {
       const comment = this._changeComments.findCommentById(
           this.params.commentId);
@@ -850,7 +849,7 @@
         // comment.patch_set vs latest
         leftSide = true;
       }
-      lineNum = comment.line;
+      this._focusLineNum = comment.line;
     } else {
       if (this.params.path) {
         this._path = this.params.path;
@@ -862,11 +861,11 @@
         };
       }
       if (this.params.lineNum) {
-        lineNum = this.params.lineNum;
+        this._focusLineNum = this.params.lineNum;
         leftSide = this.params.leftSide;
       }
     }
-    this._initLineOfInterestAndCursor(lineNum, leftSide);
+    this._initLineOfInterestAndCursor(leftSide);
     this._commentMap = this._getPaths(this._patchRange);
 
     this._commentsForDiff = this._getCommentsForPath(this._path,
@@ -890,6 +889,7 @@
     this._patchRange = undefined;
     this._commitRange = undefined;
     this._changeComments = undefined;
+    this._focusLineNum = undefined;
 
     if (value.changeNum && value.project) {
       this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
@@ -957,11 +957,11 @@
               composed: true, bubbles: true,
             }));
             GerritNav.navigateToDiff(
-                this._change, this._path, this._patchRange.basePatchNum);
+                this._change, this._path, this._patchRange.basePatchNum,
+                'PARENT', this._focusLineNum);
             return;
           }
-          if (this._isChangeCommentsLinkExperimentEnabled &&
-            !!value.commentLink) {
+          if (value.commentLink) {
             this._displayToasts();
           }
           // If the blame was loaded for a previous file and user navigates to
@@ -1012,20 +1012,20 @@
    * If the params specify a diff address then configure the diff cursor.
    */
   _initCursor(params) {
-    if (params.lineNum === undefined) { return; }
+    if (this._focusLineNum === undefined) { return; }
     if (params.leftSide) {
       this.$.cursor.side = DiffSides.LEFT;
     } else {
       this.$.cursor.side = DiffSides.RIGHT;
     }
-    this.$.cursor.initialLineNumber = params.lineNum;
+    this.$.cursor.initialLineNumber = this._focusLineNum;
   }
 
   _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};
+    if (!this._focusLineNum) { return null; }
+    return {number: this._focusLineNum, leftSide: params.leftSide};
   }
 
   _pathChanged(path) {
@@ -1210,9 +1210,10 @@
 
   _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 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 === 'LEFT' || detail.side === 'PARENT';
     const url = GerritNav.getUrlForDiffById(this._changeNum,
         this._change.project, this._path, this._patchRange.patchNum,
         this._patchRange.basePatchNum, number, leftSide);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index f0b0dcd..62963c8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -169,7 +169,8 @@
       element._change = generateChange({revisionsCount: 11});
       return element._paramsChanged.returnValues[0].then(() => {
         assert.isTrue(initLineOfInterestAndCursorStub.
-            calledWithExactly(10, true));
+            calledWithExactly(true));
+        assert.equal(element._focusLineNum, 10);
         assert.equal(element._patchRange.patchNum, 11);
         assert.equal(element._patchRange.basePatchNum, 2);
       });
@@ -207,7 +208,6 @@
           sinon.spy(element, '_paramsChanged');
           sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
               generateChange({revisionsCount: 11})));
-          element._isChangeCommentsLinkExperimentEnabled = true;
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -227,7 +227,7 @@
           element._change = generateChange({revisionsCount: 11});
           return element._paramsChanged.returnValues[0].then(() => {
             assert.isTrue(diffNavStub.lastCall.calledWithExactly(
-                element._change, '/COMMIT_MSG', 2));
+                element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
           });
         });
 
@@ -269,7 +269,6 @@
       sinon.spy(element, '_paramsChanged');
       sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
           generateChange({revisionsCount: 11})));
-      element._isChangeCommentsLinkExperimentEnabled = true;
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
@@ -1220,17 +1219,21 @@
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
       // Revision hash: specifies lineNum but not side.
-      element._initCursor({lineNum: 234});
+
+      element._focusLineNum = 234;
+      element._initCursor({});
       assert.equal(element.$.cursor.initialLineNumber, 234);
       assert.equal(element.$.cursor.side, 'right');
 
       // Base hash: specifies lineNum and side.
-      element._initCursor({leftSide: true, lineNum: 345});
+      element._focusLineNum = 345;
+      element._initCursor({leftSide: true});
       assert.equal(element.$.cursor.initialLineNumber, 345);
       assert.equal(element.$.cursor.side, 'left');
 
       // Specifies right side:
-      element._initCursor({leftSide: false, lineNum: 123});
+      element._focusLineNum = 123;
+      element._initCursor({leftSide: false});
       assert.equal(element.$.cursor.initialLineNumber, 123);
       assert.equal(element.$.cursor.side, 'right');
     });
@@ -1238,11 +1241,12 @@
     test('_getLineOfInterest', () => {
       assert.isNull(element._getLineOfInterest({}));
 
-      let result = element._getLineOfInterest({lineNum: 12});
+      element._focusLineNum = 12;
+      let result = element._getLineOfInterest({});
       assert.equal(result.number, 12);
       assert.isNotOk(result.leftSide);
 
-      result = element._getLineOfInterest({lineNum: 12, leftSide: true});
+      result = element._getLineOfInterest({leftSide: true});
       assert.equal(result.number, 12);
       assert.isOk(result.leftSide);
     });
@@ -1268,20 +1272,6 @@
       assert.isTrue(getUrlStub.called);
     });
 
-    test('_onLineSelected w/o line address', () => {
-      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
-      sinon.stub(history, 'replaceState');
-      sinon.stub(element.$.cursor, 'moveToLineNumber');
-      sinon.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');
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
index d500ea5..9f5cdf3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -189,6 +189,8 @@
 
   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.
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 b3f6a25..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ /dev/null
@@ -1,984 +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 '../../../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 {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 {FILE} from './gr-diff-line.js';
-import {DiffSide, rangesEqual} from './gr-diff-utils.js';
-import {getHiddenScroll} from '../../../scripts/hiddenscroll.js';
-import {
-  isMergeParent,
-  patchNumEquals,
-  SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.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 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';
-
-/**
- * @extends PolymerElement
- */
-class GrDiff extends 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, diff) {
-    return !loading &&
-        diff && !diff.binary &&
-        prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
-        diffLength === 0;
-  }
-
-  _enableSelectionObserver(loggedIn, isAttached) {
-    // 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(e) {
-    // To handle double-click outside of text creating comments, we check on
-    // mouse-up if there's a selection that just covers a line change. We
-    // can't do that on selection change since the user may still be dragging.
-    const selection = this._getShadowOrDocumentSelection();
-    this.$.highlights.handleSelectionChange(selection, true);
-  }
-
-  /** Gets the current selection, preferring the shadow DOM selection. */
-  _getShadowOrDocumentSelection() {
-    // When using native shadow DOM, the selection returned by
-    // document.getSelection() cannot reference the actual DOM elements making
-    // up the diff, because they are in the shadow DOM of the gr-diff element.
-    // This takes the shadow DOM selection if one exists.
-    return this.root.getSelection ?
-      this.root.getSelection() :
-      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, rootId: threadEl.rootId};
-    }
-
-    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')) || 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(
-        this.root.querySelectorAll(':not(.contextControl) > .diff-row'))
-        .filter(tr => tr.querySelector('button'));
-  }
-
-  /** @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 !== 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 = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
-    const isEditBase = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.PARENT) &&
-        patchNumEquals(this.patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
-
-    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 contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
-    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' &&
-        !isMergeParent(this.patchRange.basePatchNum)) {
-      patchNum = this.patchRange.basePatchNum;
-    }
-    return patchNum;
-  }
-
-  /** @return {boolean} */
-  _getIsParentCommentByLineAndContent(lineEl, contentEl) {
-    return (lineEl.classList.contains(DiffSide.LEFT) ||
-        contentEl.classList.contains('remove')) &&
-        (this.patchRange.basePatchNum === 'PARENT' ||
-            isMergeParent(this.patchRange.basePatchNum));
-  }
-
-  /** @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);
-        // 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 == undefined) {
-          console.error(
-              'thread attached to line ', commentSide, lineNumString,
-              ' which does not exist.');
-          continue;
-        }
-        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
-        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');
-        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 {...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);
-  }
-}
-
-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..8ac47a2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -0,0 +1,1071 @@
+/**
+ * @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 {DiffSide, 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';
+
+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';
+
+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: String})
+  projectName?: string;
+
+  @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;
+
+  @property({type: Boolean, observer: '_loadingChanged'})
+  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;
+
+  /**
+   * 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: 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, 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()
+      : 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() {
+    if (this.hidden && this.noAutoRender) return [];
+    if (!this.root) return [];
+
+    return Array.from(
+      this.root.querySelectorAll(':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') ? DiffSide.LEFT : DiffSide.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(DiffSide.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(DiffSide.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(DiffSide.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(DiffSide.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();
+  }
+
+  _loadingChanged(newValue?: boolean) {
+    if (newValue) {
+      this._cleanup();
+    }
+  }
+
+  _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) {
+    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 &&
+      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._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.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index f18af23..f9656b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -177,6 +177,14 @@
       background-color: var(--light-remove-add-highlight-color);
     }
 
+    /* dueToMove */
+    .dueToMove .content.add .contentText {
+      background-color: var(--light-moved-add-highlight-color);
+    }
+    .dueToMove .content.remove .contentText {
+      background-color: var(--light-remove-add-highlight-color);
+    }
+
     /* ignoredWhitespaceOnly */
     .ignoredWhitespaceOnly .content.add .contentText .intraline,
     .delta.total.ignoredWhitespaceOnly .content.add .contentText,
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
index 3e5cfb5..b6e1885 100644
--- 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
@@ -405,7 +405,7 @@
     addFrontSpace?: boolean
   ) {
     const rev = getRevisionByPatchNum(revisions, patchNum);
-    return rev && rev.description
+    return rev?.description
       ? (addFrontSpace ? ' ' : '') +
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
       : '';
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 12cd971..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ /dev/null
@@ -1,281 +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 '../../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-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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.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 {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
-import {computeTruncatedPath} from '../../../utils/path-list-util.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 PolymerElement
- */
-class GrEditorView extends KeyboardShortcutMixin(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: Object,
-      _changeEditDetail: Object,
-      _changeNum: String,
-      _patchNum: String,
-      _path: String,
-      _type: String,
-      _content: String,
-      _newContent: String,
-      _saving: {
-        type: Boolean,
-        value: false,
-      },
-      _successfulSave: {
-        type: Boolean,
-        value: false,
-      },
-      _saveDisabled: {
-        type: Boolean,
-        value: true,
-        computed: '_computeSaveDisabled(_content, _newContent, _saving)',
-      },
-      _prefs: Object,
-      _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 || SPECIAL_PATCH_SET_NUM.EDIT;
-    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 ${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;
-    });
-  }
-
-  _handlePathChanged(e) {
-    const path = e.detail;
-    if (path === this._path) {
-      return Promise.resolve();
-    }
-    return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
-        this._path, path).then(res => {
-      if (!res.ok) { return; }
-
-      this._successfulSave = true;
-      this._viewEditInChangeView();
-    });
-  }
-
-  _viewEditInChangeView() {
-    const patch = this._successfulSave ? SPECIAL_PATCH_SET_NUM.EDIT
-      : this._patchNum;
-    GerritNav.navigateToChange(this._change, patch, null,
-        patch !== SPECIAL_PATCH_SET_NUM.EDIT);
-  }
-
-  _getFileData(changeNum, path, patchNum) {
-    const storedContent =
-          this.$.storage.getEditableContentItem(this.storageKey);
-
-    return this.$.restAPI.getFileContent(changeNum, path, patchNum)
-        .then(res => {
-          const content = (res && res.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() {
-    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,
-    ].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();
-  }
-
-  _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..b75c26d
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -0,0 +1,356 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES 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} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ChangeInfo,
+  PatchSetNum,
+  EditPreferencesInfo,
+  Base64FileContent,
+} from '../../../types/common';
+import {GrStorage} from '../../shared/gr-storage/gr-storage';
+
+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;
+
+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})
+  _change?: ChangeInfo | null;
+
+  @property({type: Number})
+  _changeNum?: number;
+
+  @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'
+        ? parseInt(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: number) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      this._change = change;
+    });
+  }
+
+  _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() {
+    const patch = this._successfulSave
+      ? (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum)
+      : this._patchNum;
+    if (this._change && patch)
+      GerritNav.navigateToChange(
+        this._change,
+        patch,
+        undefined,
+        patch !== SPECIAL_PATCH_SET_NUM.EDIT
+      );
+  }
+
+  _getFileData(changeNum: number, 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;
+        }
+
+        this._content = this._newContent;
+        this._successfulSave = true;
+      });
+  }
+
+  _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();
+  }
+
+  _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_test.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
index a673955..d8f130c 100644
--- 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
@@ -283,6 +283,7 @@
   });
 
   test('_viewEditInChangeView respects _patchNum', () => {
+    element._change = {};
     navigateStub.restore();
     const navStub = sinon.stub(GerritNav, 'navigateToChange');
     element._patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index a304a1b..63e3caa 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -438,8 +438,10 @@
       flush();
       const registrationOverlay =
         this.shadowRoot.querySelector('#registrationOverlay');
+      const registrationDialog =
+        this.shadowRoot.querySelector('#registrationDialog');
       registrationOverlay.open();
-      registrationOverlay.loadData().then(() => {
+      registrationDialog.loadData().then(() => {
         registrationOverlay.refit();
       });
     }
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
similarity index 68%
rename from polygerrit-ui/app/elements/gr-app-global-var-init.js
rename to polygerrit-ui/app/elements/gr-app-global-var-init.ts
index 30ff123..e609e6f 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -22,54 +22,81 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {getAccountDisplayName, getDisplayName, getGroupDisplayName, getUserName} from '../utils/display-name-util.js';
-import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation.js';
-import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper.js';
-import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} 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 {getPluginEndpoints, 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 {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 {page} from '../utils/page-wrapper-utils.js';
-import {appContext} from '../services/app-context.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 {getPluginLoader, 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 {getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
-import {getBaseUrl} from '../utils/url-util.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';
+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';
+import {GerritGlobal} from './shared/gr-js-api-interface/gr-gerrit';
 
 export function initGlobalVariables() {
   window.GrDisplayNameUtils = {
@@ -92,7 +119,6 @@
   window.GrChangeActionsInterface = GrChangeActionsInterface;
   window.GrChangeReplyInterface = GrChangeReplyInterface;
   window.GrEditConstants = GrEditConstants;
-  window.GrFileListConstants = GrFileListConstants;
   window.GrDomHooksManager = GrDomHooksManager;
   window.GrDomHook = GrDomHook;
   window.GrEtagDecorator = GrEtagDecorator;
@@ -133,7 +159,7 @@
     PLUGIN_LOADING_TIMEOUT_MS,
   };
 
-  window.Gerrit = window.Gerrit || {};
+  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
   window.Gerrit.Nav = GerritNav;
   window.Gerrit.getRootElement = getRootElement;
   window.Gerrit.Auth = appContext.authService;
@@ -142,10 +168,10 @@
   // TODO: should define as a getter
   window.Gerrit._endpoints = getPluginEndpoints();
 
-  window.Gerrit.slotToContent = slot => slot;
+  // 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.SUGGESTIONS_PROVIDERS_USERS_TYPES = SUGGESTIONS_PROVIDERS_USERS_TYPES;
   window.Gerrit.RevisionInfo = RevisionInfo;
   window.Gerrit.CoverageType = CoverageType;
   Object.defineProperty(window.Gerrit, 'hiddenscroll', {
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
index e4cf3a1..d970b0a 100644
--- 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
@@ -164,25 +164,30 @@
   }
 
   _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
+    return this._hasDisplayNameChange &&
+      this._account?.display_name !== undefined
       ? this.$.restAPI.setAccountDisplayName(this._account.display_name)
       : Promise.resolve();
   }
 
   _maybeSetStatus() {
-    return this._hasStatusChange && this._account?.status
+    return this._hasStatusChange && this._account?.status !== undefined
       ? this.$.restAPI.setAccountStatus(this._account.status)
       : Promise.resolve();
   }
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 5f1f9b0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ /dev/null
@@ -1,369 +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 '../gr-account-chip/gr-account-chip.js';
-import '../gr-account-entry/gr-account-entry.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-list_html.js';
-import {appContext} from '../../../services/app-context.js';
-
-const VALID_EMAIL_ALERT = 'Please input a valid email.';
-
-/**
- * @extends PolymerElement
- */
-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,
-      },
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('remove',
-        e => this._handleRemove(e));
-  }
-
-  get accountChips() {
-    return Array.from(
-        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.
-    let itemTypeAdded = 'unknown';
-    if (item.account) {
-      const account =
-          {...item.account, _pendingAdd: true};
-      this.push('accounts', account);
-      itemTypeAdded = 'account';
-    } else if (item.group) {
-      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) {
-    group = {
-      ...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);
-        this.reporting.reportInteraction(`Remove from ${this.id}`);
-        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..fc0ea79
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -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 '../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
+interface AccountInfoInput extends AccountInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+// Internal input type with group info
+interface GroupInfoInput extends GroupInfo {
+  _group?: boolean;
+  _account?: boolean;
+  _pendingAdd?: boolean;
+  confirmed?: boolean;
+}
+
+type AccountInput = AccountInfoInput | 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: RawAccountInput | 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: GroupObjectInput) {
+    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() {
+    return this.accounts
+      .filter(account => account._pendingAdd)
+      .map(account => {
+        if (account._group) {
+          return {group: account};
+        } else {
+          return {account};
+        }
+      });
+  }
+
+  _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-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index 046218b..e5806f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -74,7 +74,7 @@
   /** @override */
   attached() {
     super.attached();
-    this._boundTransitionEndHandler = this._handleTransitionEnd.bind(this);
+    this._boundTransitionEndHandler = () => this._handleTransitionEnd();
     this.addEventListener('transitionend', this._boundTransitionEndHandler);
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index ea573ab..033b617 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -29,17 +29,17 @@
   CustomKeyboardEvent,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement, observe} from '@polymer/decorators';
-import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {EventWithPath} from '../../plugins/gr-event-helper/gr-event-helper';
+import {PaperInputElementExt} from '../../../types/types';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
 
 export interface GrAutocomplete {
   $: {
-    input: PaperInputElement & {$: {nativeInput?: Element}};
+    input: PaperInputElementExt;
     suggestions: GrAutocompleteDropdown;
     cursor: GrCursorManager;
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index 3b7dbfe..5de7a66 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -29,7 +29,6 @@
 import {appContext} from '../../../services/app-context.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {computeDisplayPath} from '../../../utils/path-list-util.js';
-import {KnownExperimentId} from '../../../services/flags/flags.js';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -192,8 +191,6 @@
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
-    this._isChangeCommentsLinkExperimentEnabled = this.flagsService
-        .isEnabled(KnownExperimentId.PATCHSET_CHOICE_FOR_COMMENT_LINKS);
     this._setInitialExpandedState();
   }
 
@@ -228,8 +225,7 @@
   }
 
   _getDiffUrlForPath(path) {
-    if (!this._isChangeCommentsLinkExperimentEnabled ||
-      this.comments[0].__draft) {
+    if (this.comments[0].__draft) {
       return GerritNav.getUrlForDiffById(this.changeNum,
           this.projectName, path, this.patchNum);
     }
@@ -238,8 +234,7 @@
   }
 
   _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
-    if (!this._isChangeCommentsLinkExperimentEnabled ||
-      (this.comments.length && this.comments[0].side === 'PARENT') ||
+    if ((this.comments.length && this.comments[0].side === 'PARENT') ||
       this.comments[0].__draft) {
       return GerritNav.getUrlForDiffById(changeNum,
           projectName, path, patchNum, null, this.lineNum);
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
index ab93cb6..a25a4c7 100644
--- 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
@@ -186,7 +186,6 @@
       element.lineNum = 5;
       element.comments = [{id: 'comment_id'}];
       element.showFilePath = true;
-      element._isChangeCommentsLinkExperimentEnabled = true;
       flushAsynchronousOperations();
       assert.isOk(element.shadowRoot
           .querySelector('.pathInfo'));
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 0c896763..edadfba 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -29,7 +29,7 @@
 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 {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {PaperInputElementExt} from '../../../types/types';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -42,7 +42,7 @@
 
 export interface GrEditableLabel {
   $: {
-    input: PaperInputElement;
+    input: PaperInputElementExt;
     dropdown: IronDropdownElement;
   };
 }
@@ -188,12 +188,9 @@
   }
 
   get _nativeInput(): HTMLInputElement {
-    // In Polymer 2, the namespace of nativeInput changed from input to
-    // nativeInput.
-    // `this.$.input` has type PaperInputElement, so this is beyond our control
-    // and we cannot force `this.$.input.$` to have a proper type.
-    return (this.$.input.$['nativeInput'] ||
-      this.$.input.$['input']) as HTMLInputElement;
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    return (this.$.input.$.nativeInput ||
+      this.$.input.inputElement) as HTMLInputElement;
   }
 
   _handleEnter(e: CustomKeyboardEvent) {
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
index affa8b5..c4ebe1e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -24,6 +24,10 @@
 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';
 
 const HOVER_CLASS = 'hovered';
 const HIDE_CLASS = 'hide';
@@ -127,8 +131,10 @@
         // 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');
       }
 
       /** @override */
@@ -213,6 +219,13 @@
       }
 
       /**
+       * 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).
@@ -287,6 +300,13 @@
       }
 
       /**
+       * 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.
        */
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
index 4a47a13..0cb628c 100644
--- 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
@@ -17,7 +17,6 @@
 
 import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation';
 import {GrStyleObject} from '../../plugins/gr-styles-api/gr-styles-api';
-import {PatchSetNum} from '../../../types/common';
 import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
 
 /**
@@ -43,15 +42,12 @@
 
   changeNum: number;
 
-  patchNum: number;
-
   constructor(
     contentEl: HTMLElement,
     lineNumberEl: HTMLElement,
     line: GrDiffLine,
     path: string,
-    changeNum: string | number,
-    patchNum: PatchSetNum
+    changeNum: string | number
   ) {
     this._contentEl = contentEl;
     this._lineNumberEl = lineNumberEl;
@@ -59,9 +55,10 @@
     this.line = line;
     this.path = path;
     this.changeNum = Number(changeNum);
-    this.patchNum = Number(patchNum);
-    if (isNaN(this.changeNum) || isNaN(this.patchNum)) {
-      console.error('invalid parameters');
+    if (isNaN(this.changeNum)) {
+      console.error(
+        `GrAnnotationActionsContext: Invalid changeNum: ${changeNum}`
+      );
     }
   }
 
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
index 80e09d4..331fb42 100644
--- 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
@@ -18,7 +18,6 @@
 import {GrDiffLine, LineNumber} from '../../diff/gr-diff/gr-diff-line';
 import {CoverageRange} from '../../../types/types';
 import {Side} from '../../../constants/constants';
-import {PatchSetNum} from '../../../types/common';
 import {PluginApi} from '../../plugins/gr-plugin-types';
 
 type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
@@ -188,13 +187,11 @@
    *
    * @param path The file path (eg: /COMMIT_MSG').
    * @param changeNum The Gerrit change number.
-   * @param patchNum The Gerrit patch number.
    */
-  getLayer(path: string, changeNum: number, patchNum: number) {
+  getLayer(path: string, changeNum: number) {
     const annotationLayer = new AnnotationLayer(
       path,
       changeNum,
-      patchNum,
       this.addLayerFunc
     );
     this.annotationLayers.push(annotationLayer);
@@ -216,14 +213,12 @@
    *
    * @param path The file path (eg: /COMMIT_MSG').
    * @param changeNum The Gerrit change number.
-   * @param patchNum The Gerrit patch 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 patchNum: number,
     private readonly addLayerFunc: AddLayerFunc
   ) {
     this.listeners = [];
@@ -264,8 +259,7 @@
       lineNumberEl,
       line,
       this.path,
-      this.changeNum,
-      this.patchNum as PatchSetNum
+      this.changeNum
     );
     this.addLayerFunc(annotationActionsContext);
   }
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
index e819529..8b3f501 100644
--- 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
@@ -52,19 +52,17 @@
     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);
+        '/dummy/path', changeNum);
 
     const lineNumberEl = document.createElement('td');
     annotationLayer.annotate(el, lineNumberEl, line);
@@ -74,8 +72,8 @@
   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 annotationLayer1 = annotationActions.getLayer(path1, 1);
+    const annotationLayer2 = annotationActions.getLayer(path2, 1);
     const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
     const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
 
@@ -148,8 +146,7 @@
   });
 
   test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.getLayer(
-        '/dummy/path', 1, 2);
+    const annotationLayer = annotationActions.getLayer('/dummy/path', 1);
     let listenerCalledTimes = 0;
     const startRange = 10;
     const endRange = 20;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 7f0f5b7..9e9a0ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -19,7 +19,11 @@
  * This defines the Gerrit instance. All methods directly attached to Gerrit
  * should be defined or linked here.
  */
-import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
+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';
@@ -29,8 +33,15 @@
   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';
 
-interface GerritGlobal extends EventEmitterService {
+export interface GerritGlobal extends EventEmitterService {
   flushPreinstalls?(): void;
   css(rule: string): string;
   install(
@@ -60,6 +71,18 @@
   _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;
 }
 
 /**
@@ -67,7 +90,7 @@
  * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
  */
 function flushPreinstalls() {
-  const Gerrit = window.Gerrit as GerritGlobal;
+  const Gerrit = window.Gerrit;
   if (Gerrit.flushPreinstalls) {
     Gerrit.flushPreinstalls();
   }
@@ -75,9 +98,9 @@
 export const _testOnly_flushPreinstalls = flushPreinstalls;
 
 export function initGerritPluginApi() {
-  window.Gerrit = {};
+  window.Gerrit = (window.Gerrit || {}) as GerritGlobal;
   flushPreinstalls();
-  initGerritPluginsMethods(window.Gerrit as GerritGlobal);
+  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)
@@ -86,7 +109,7 @@
 
 export function _testOnly_initGerritPluginApi(): GerritGlobal {
   initGerritPluginApi();
-  return window.Gerrit as GerritGlobal;
+  return window.Gerrit;
 }
 
 export function deprecatedDelete(
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
index 33ad3ca..ff9446c 100644
--- 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
@@ -247,12 +247,12 @@
     return revertSubmissionMsg;
   }
 
-  getDiffLayers(path: string, changeNum: number, patchNum: number) {
+  getDiffLayers(path: string, changeNum: number) {
     const layers = [];
     for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
       const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
       try {
-        const layer = annotationApi.getLayer(path, changeNum, patchNum);
+        const layer = annotationApi.getLayer(path, changeNum);
         layers.push(layer);
       } catch (err) {
         console.error(err);
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
index 7455cad..0a5dbc9 100644
--- 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
@@ -39,5 +39,10 @@
     origMsg: string
   ): string;
   handleEvent(eventName: EventType, detail: any): void;
+  modifyRevertMsg(
+    change: ChangeInfo,
+    revertMsg: string,
+    origMsg: string
+  ): string;
   // TODO(TS): Add more methods when needed for the TS conversion.
 }
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
index ddf4c21..0bf6676 100644
--- 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
@@ -37,4 +37,11 @@
       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
index e05dff3..1229641 100644
--- 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
@@ -57,5 +57,19 @@
           {}
       );
     });
+
+    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 a6020cf..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ /dev/null
@@ -1,187 +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 '../../../styles/gr-voting-styles.js';
-import '../../../styles/shared-styles.js';
-import '../gr-account-label/gr-account-label.js';
-import '../gr-account-link/gr-account-link.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 PolymerElement */
-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, suppressing 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..b6e664e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -0,0 +1,262 @@
+/**
+ * @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,
+  DetailedLabelInfo,
+  QuickLabelInfo,
+  ApprovalInfo,
+  AccountId,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../gr-button/gr-button';
+
+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;
+}
+
+// type guard to check if label is QuickLabelInfo
+function isQuickLabelInfo(label: LabelInfo): label is QuickLabelInfo {
+  return !(label as DetailedLabelInfo).values;
+}
+
+@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 || !account) {
+      return result;
+    }
+    if (isQuickLabelInfo(labelInfo)) {
+      if (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 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 = LabelClassName.MAX;
+          } else {
+            labelClassName = LabelClassName.POSITIVE;
+          }
+        } else if (label.value < 0) {
+          if (parseInt(`${label.value}`, 10) === parseInt(values[0], 10)) {
+            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 = parseInt(
+      `${target.getAttribute('data-account-id')}`,
+      10
+    ) 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 ||
+      isQuickLabelInfo(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 &&
+      isQuickLabelInfo(labelInfo) &&
+      (labelInfo.rejected || labelInfo.approved)
+    ) {
+      return 'hidden';
+    }
+
+    // TODO(TS): might replace with hasOwnProperty instead
+    if (labelInfo && (labelInfo as DetailedLabelInfo).all) {
+      for (const label of (labelInfo as DetailedLabelInfo).all || []) {
+        if (label.value && label.value !== labelInfo.default_value) {
+          return 'hidden';
+        }
+      }
+    }
+    return '';
+  }
+}
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
index a2d1e1a..3955cd4 100644
--- 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
@@ -23,7 +23,6 @@
   <style include="shared-styles">
     .placeholder {
       color: var(--deemphasized-text-color);
-      padding-top: var(--spacing-xs);
     }
     .hidden {
       display: none;
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
index 0fbaba2..b936237 100644
--- 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
@@ -21,7 +21,7 @@
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
-suite('gr-account-link tests', () => {
+suite('gr-label-info tests', () => {
   let element;
 
   setup(() => {
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
index b18439b..ad97d02 100644
--- 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
@@ -73,7 +73,7 @@
       if (!this._hljsState.loading) {
         this._hljsState.loading = true;
         this._loadScript(this._getHLJSUrl())
-          .then(this._onHLJSLibLoaded.bind(this))
+          .then(() => this._onHLJSLibLoaded())
           .catch(reject);
       }
 
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
index 4df91e70..e2c2d7f 100644
--- 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
@@ -93,7 +93,8 @@
     output.textContent = '';
     const parser = new GrLinkTextParser(
       config,
-      this._handleParseResult.bind(this),
+      (text: string | null, href: string | null, fragment?: DocumentFragment) =>
+        this._handleParseResult(text, href, fragment),
       this.removeZeroWidthSpace
     );
     parser.parse(content);
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
index 451c627..9066911 100644
--- 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
@@ -289,7 +289,7 @@
   parse(text?: string | null) {
     if (text) {
       window.linkify(text, {
-        callback: this.parseChunk.bind(this),
+        callback: (text: string, href?: string) => this.parseChunk(text, href),
       });
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 103c71d..f4111e7 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -40,7 +40,6 @@
   getParentIndex,
   isMergeParent,
   patchNumEquals,
-  SPECIAL_PATCH_SET_NUM,
 } from '../../../utils/patch-set-util';
 import {
   ListChangesOption,
@@ -1126,7 +1125,7 @@
     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
@@ -2471,9 +2470,7 @@
     };
     if (isMergeParent(basePatchNum)) {
       params.parent = getParentIndex(basePatchNum);
-    } else if (
-      !patchNumEquals(basePatchNum, SPECIAL_PATCH_SET_NUM.PARENT as PatchSetNum)
-    ) {
+    } else if (!patchNumEquals(basePatchNum, ParentPatchSetNum)) {
       // TODO (TS): fix as PatchSetNum in the condition above
       params.base = basePatchNum;
     }
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 2ca1118..0ceca3c 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,6 +6,11 @@
     bazel_bin=bazel
 fi
 
+# 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_verbose_timeout_warnings \
+      --test_output=all \
       //polygerrit-ui:karma_test
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 76edae8..743e0f4 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-export type EventValue = string | number | {error: Error};
+export type EventValue = string | number | {error?: Error};
 
 // TODO(dmfilippov): TS-fix-any use more specific type instead if possible
 export type EventDetails = any;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 1cf4bea..29cdd1e 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -117,11 +117,11 @@
   // TODO(dmfilippo): TS-fix-any oldOnError - define correct type
   const onError = function (
     oldOnError: Function,
-    msg: string,
-    url: string,
-    line: number,
-    column: number,
-    error: Error
+    msg: Event | string,
+    url?: string,
+    line?: number,
+    column?: number,
+    error?: Error
   ) {
     if (oldOnError) {
       oldOnError(msg, url, line, column, error);
@@ -147,7 +147,7 @@
     reportingService.reporter(
       ERROR.TYPE,
       ERROR.CATEGORY.EXCEPTION,
-      msg,
+      `${msg}`,
       payload
     );
     return true;
@@ -155,7 +155,16 @@
   // TODO(dmfilippov): TS-fix-any unclear what is context
   const catchErrors = function (opt_context?: any) {
     const context = opt_context || window;
-    context.onerror = onError.bind(null, context.onerror);
+    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) => {
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
index c6555d0..e8fc3f9 100644
--- 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
@@ -74,6 +74,10 @@
   NameToProjectInfoMap,
   ProjectInput,
   AccountId,
+  ChangeMessageId,
+  GroupAuditEventInfo,
+  EncodedGroupId,
+  Base64FileContent,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -533,6 +537,10 @@
 
   getAccountGroups(): Promise<GroupInfo[] | undefined>;
 
+  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined>;
+
+  getAccountStatus(userId: AccountId): Promise<string | undefined>;
+
   saveAccountAgreement(name: ContributorAgreementInput): Promise<Response>;
 
   generateAccountHttpPassword(): Promise<Password>;
@@ -585,4 +593,73 @@
   ): Promise<void>;
   setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void>;
   getAvatarChangeUrl(): Promise<string | undefined>;
+  setDescription(
+    changeNum: ChangeNum,
+    patchNum: PatchSetNum,
+    desc: string
+  ): Promise<Response>;
+  deleteVote(
+    changeNum: ChangeNum,
+    account: AccountId,
+    label: string
+  ): Promise<Response>;
+
+  deleteChangeCommitMessage(
+    changeNum: ChangeNum,
+    messageId: ChangeMessageId
+  ): Promise<Response>;
+
+  removeChangeReviewer(
+    changeNum: ChangeNum,
+    reviewerID: AccountId | GroupId
+  ): Promise<Response | undefined>;
+
+  getGroupAuditLog(
+    group: EncodedGroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupAuditEventInfo[] | undefined>;
+
+  getGroupMembers(
+    groupName: GroupId,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[] | undefined>;
+
+  getIncludedGroup(groupName: GroupId): Promise<GroupInfo[] | undefined>;
+
+  saveGroupMember(
+    groupName: GroupId,
+    groupMember: AccountId
+  ): Promise<AccountInfo>;
+
+  saveIncludedGroup(
+    groupName: GroupId,
+    includedGroup: GroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined>;
+
+  deleteGroupMember(
+    groupName: GroupId,
+    groupMember: AccountId
+  ): Promise<Response>;
+
+  deleteIncludedGroup(
+    groupName: GroupId,
+    includedGroup: GroupId
+  ): Promise<Response>;
+
+  runRepoGC(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<Response | undefined>;
+  getFileContent(
+    changeNum: ChangeNum,
+    path: string,
+    patchNum: PatchSetNum
+  ): Promise<Response | Base64FileContent | undefined>;
+
+  saveChangeEdit(
+    changeNum: ChangeNum,
+    path: string,
+    contents: string
+  ): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 04dca9c..fbc62e2 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -135,6 +135,7 @@
 
       /* Stopgap solution until we remove hidden$ attributes. */
 
+      :host([hidden]),
       [hidden] {
         display: none !important;
       }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 2706b22..835ef0a 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -167,6 +167,7 @@
     --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;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 2fa66f0..3032984 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -117,6 +117,7 @@
       --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;
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 004daf7..500187a 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -31,6 +31,7 @@
 import sinon from 'sinon/pkg/sinon-esm.js';
 import {safeTypesBridge} from '../utils/safe-types-util.js';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit.js';
+import {initGlobalVariables} from '../elements/gr-app-global-var-init.js';
 window.sinon = sinon;
 
 security.polymer_resin.install({
@@ -60,6 +61,9 @@
 };
 
 setup(() => {
+  window.Gerrit = {};
+  initGlobalVariables();
+
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(cleanups.length, 0);
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 54846cd..cc74ef1 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -52,7 +52,7 @@
  */
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
-export type PatchSetNum = BrandType<'edit' | number, '_patchSet'>;
+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
@@ -140,8 +140,12 @@
  * 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
  */
-type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
+export type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
 
+export type Reviewers = {
+  REVIEWER?: AccountInfo[];
+  CC?: AccountInfo[];
+};
 interface LabelCommonInfo {
   optional?: boolean; // not set if false
 }
@@ -159,6 +163,7 @@
 export interface DetailedLabelInfo extends LabelCommonInfo {
   all?: ApprovalInfo[];
   values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+  default_value?: number;
 }
 
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
@@ -210,10 +215,7 @@
   labels?: LabelNameToInfoMap;
   permitted_labels?: LabelNameToValueMap;
   removable_reviewers?: AccountInfo[];
-  reviewers?: {
-    REVIEWER?: AccountInfo[];
-    CC?: AccountInfo[];
-  };
+  reviewers?: Reviewers;
   pending_reviewers?: AccountInfo[];
   reviewer_updates?: ReviewerUpdateInfo[];
   messages?: ChangeMessageInfo[];
@@ -409,6 +411,8 @@
 export interface ChangeMessageInfo {
   id: ChangeMessageId;
   author?: AccountInfo;
+  reviewer?: AccountInfo;
+  updated_by?: AccountInfo;
   real_author?: AccountInfo;
   date: Timestamp;
   message: string;
@@ -1119,6 +1123,7 @@
   edit_a?: number[][];
   edit_b?: number[][];
   due_to_rebase?: boolean;
+  due_to_move?: boolean;
   skip?: string;
   common?: string;
   keyLocation?: boolean;
@@ -1145,7 +1150,7 @@
   meta_b: DiffFileMetaInfo;
   change_type: string;
   intraline_status: string;
-  diff_header: string;
+  diff_header: string[];
   content: DiffContent[];
   web_links?: DiffWebLinkInfo[];
   binary: boolean;
@@ -1197,6 +1202,7 @@
   // Either remove or update doc
   theme?: string;
 }
+export type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
 
 /**
  * The RangeInfo entity stores the coordinates of a range.
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index bdfb8c4..645c991 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -36,7 +36,7 @@
     ): void;
     ASSETS_PATH?: string;
     // TODO(TS): define gerrit type
-    Gerrit?: unknown;
+    Gerrit?: any;
     // TODO(TS): define polymer type
     Polymer?: unknown;
     // TODO(TS): remove page when better workaround is found
@@ -51,6 +51,56 @@
       dashboardPage?: string;
     };
     STATIC_RESOURCE_PATH?: 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: any;
+    GrAnnotation: any;
+    GrAttributeHelper: any;
+    GrDiffLine: any;
+    GrDiffLineType: any;
+    GrDiffGroup: any;
+    GrDiffGroupType: any;
+    GrDiffBuilder: any;
+    GrDiffBuilderSideBySide: any;
+    GrDiffBuilderImage: any;
+    GrDiffBuilderUnified: any;
+    GrDiffBuilderBinary: any;
+    GrChangeActionsInterface: any;
+    GrChangeReplyInterface: any;
+    GrEditConstants: any;
+    GrDomHooksManager: any;
+    GrDomHook: any;
+    GrEtagDecorator: any;
+    GrThemeApi: any;
+    SiteBasedCache: any;
+    FetchPromisesCache: any;
+    GrRestApiHelper: any;
+    GrLinkTextParser: any;
+    GrPluginEndpoints: any;
+    GrReviewerUpdatesParser: any;
+    GrPopupInterface: any;
+    GrCountStringFormatter: any;
+    GrReviewerSuggestionsProvider: any;
+    util: any;
+    Auth: any;
+    EventEmitter: any;
+    GrAdminApi: any;
+    GrAnnotationActionsContext: any;
+    GrAnnotationActionsInterface: any;
+    GrChangeMetadataApi: any;
+    GrEmailSuggestionsProvider: any;
+    GrGroupSuggestionsProvider: any;
+    GrEventHelper: any;
+    GrPluginRestApi: any;
+    GrRepoApi: any;
+    GrSettingsApi: any;
+    GrStylesApi: any;
+    PluginLoader: any;
+    GrPluginActionContext: any;
+    _apiUtils: {};
   }
 
   interface Performance {
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index bd02daf..970fa21 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -17,6 +17,8 @@
 import {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';
 
 export function notUndefined<T>(x: T | undefined): x is T {
   return x !== undefined;
@@ -59,13 +61,31 @@
 }
 
 /**
+ * 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;
 }
@@ -106,6 +126,8 @@
    * 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 */
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index ba95690..de43884c 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,16 +16,7 @@
  */
 import {getBaseUrl} from './url-util';
 import {ChangeStatus} from '../constants/constants';
-import {LegacyChangeId, PatchSetNum} from '../types/common';
-
-// This can be wrong! See WARNING above
-interface Change {
-  status: string; // This can be wrong! See WARNING above
-  mergeable: boolean; // This can be wrong! See WARNING above
-  work_in_progress: boolean; // This can be wrong! See WARNING above
-  is_private: boolean; // This can be wrong! See WARNING above
-  submittable: boolean; // This can be wrong! See WARNING above
-}
+import {LegacyChangeId, PatchSetNum, ChangeInfo} from '../types/common';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -132,12 +123,12 @@
   return `${getBaseUrl()}/c/${changeNum}`;
 }
 
-export function changeIsOpen(change?: Change) {
-  return change && change.status === ChangeStatus.NEW;
+export function changeIsOpen(change?: ChangeInfo) {
+  return change?.status === ChangeStatus.NEW;
 }
 
 export function changeStatuses(
-  change: Change,
+  change: ChangeInfo,
   opt_options?: ChangeStatusesOptions
 ) {
   const states = [];
@@ -175,6 +166,6 @@
   return states;
 }
 
-export function changeStatusString(change: Change) {
+export function changeStatusString(change: ChangeInfo) {
   return changeStatuses(change).join(', ');
 }
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index d8e1797..ff55e1f 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -1,4 +1,9 @@
-import {RevisionInfo, ChangeInfo, PatchSetNum} from '../types/common';
+import {
+  RevisionInfo,
+  ChangeInfo,
+  PatchSetNum,
+  EditPatchSetNum,
+} from '../types/common';
 import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
 import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
@@ -28,6 +33,8 @@
 // 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',
@@ -84,7 +91,7 @@
   revisions: RevisionInfo[],
   patchNum: PatchSetNum
 ) {
-  for (const rev of Object.values(revisions || {})) {
+  for (const rev of revisions) {
     if (patchNumEquals(rev._number, patchNum)) {
       return rev;
     }
@@ -101,9 +108,7 @@
  *
  */
 export function findEditParentRevision(revisions: RevisionInfo[]) {
-  const editInfo = revisions.find(
-    info => info._number === SPECIAL_PATCH_SET_NUM.EDIT
-  );
+  const editInfo = revisions.find(info => info._number === EditPatchSetNum);
 
   if (!editInfo) {
     return null;
@@ -143,7 +148,7 @@
   // 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 === SPECIAL_PATCH_SET_NUM.EDIT
+    r._number === EditPatchSetNum
       ? 2 * editParent
       : 2 * ((r._number as number) - 1) + 1;
   return revisions.sort((a, b) => num(b) - num(a));
@@ -236,7 +241,7 @@
   if (!allPatchSets || !allPatchSets.length) {
     return undefined;
   }
-  if (allPatchSets[0].num === SPECIAL_PATCH_SET_NUM.EDIT) {
+  if (allPatchSets[0].num === EditPatchSetNum) {
     return allPatchSets[1].num;
   }
   return allPatchSets[0].num;
@@ -246,7 +251,7 @@
   if (!allPatchSets || allPatchSets.length < 2) {
     return false;
   }
-  return allPatchSets[0].num === SPECIAL_PATCH_SET_NUM.EDIT;
+  return allPatchSets[0].num === EditPatchSetNum;
 }
 
 export function hasEditPatchsetLoaded(patchRangeRecord: PatchRangeRecord) {
@@ -255,8 +260,8 @@
     return false;
   }
   return (
-    patchRange.patchNum === SPECIAL_PATCH_SET_NUM.EDIT ||
-    patchRange.basePatchNum === SPECIAL_PATCH_SET_NUM.EDIT
+    patchRange.patchNum === EditPatchSetNum ||
+    patchRange.basePatchNum === EditPatchSetNum
   );
 }
 
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index fc92b31..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.
-    {if $group.link}{$group.link}{sp}{/if}{\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 617c8d17..21fee18 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -111,9 +111,7 @@
     {for $group in $commentFiles}
       <li style="{$fileLiStyle}">
         <p>
-          {if $group.link}<a href="{$group.link}">{/if}
           {$group.title}:
-          {if $group.link}</a>{/if}
         </p>
 
         <ul style="{$ulStyle}">