Merge "PatchsetOperationsImplTest: Use merge commits for related comment tests"
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 d96ae46..f02d89a 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -182,20 +182,6 @@
 +
 By default 1.
 
-[[asyncPostUpdate]]
-=== Section asyncPostUpdate
-
-[[asyncPostUpdate.threadPoolSize]]asyncPostUpdate.threadPoolSize::
-+
-Maximum size of thread pool in which async post updates are sent out.
-+
-When set to 0, a direct executor is used.
-+
-When unset, use link:#sendemail[sendemail.threadPoolSize] threadPoolSize. When both
-are unset, use the default.
-+
-By default, 1.
-
 [[auth]]
 === Section auth
 
@@ -884,8 +870,6 @@
 The check if they are is cheap and always happens on the thread that
 inquires for a cached value.
 +
-When set to 0, a direct executor is used.
-+
 Defaults to 2.
 
 ==== [[cache_names]]Standard Caches
@@ -2828,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.
@@ -4489,7 +4474,11 @@
 an error occurs.
 
 [[sendemail.threadPoolSize]]sendemail.threadPoolSize::
-Deprecated. Replaced with link:#asyncPostUpdate.threadPoolSize[asyncPostUpdate.threadPoolSize]
++
+Maximum size of thread pool in which the review comments
+notifications are sent out asynchronously.
++
+By default, 1.
 
 [[sendemail.from]]sendemail.from::
 +
@@ -4881,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
@@ -5006,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.
@@ -5500,6 +5505,23 @@
   trustFolderStat = false
 ----
 
+[[jgit-protocol]]
+=== Section protocol
+
+[[protocol.version]]protocol.version::
++
+If set, the server will accept requests from a client attempting to communicate
+using the specified protocol version. Otherwise communication falls back to version 0.
+If set in file `etc/jgit.config` this option will be used for all repositories of
+the site. It can be overridden for a given repository by configuring a different
+value in the repository's `config` file.
++
+Supported versions:
+0:: the original wire protocol.
+1:: the original wire protocol with the addition of a version string in the initial response from the server.
+2:: wire protocol version 2. Speeds up fetches from repositories with many refs by allowing the client
+    to specify which refs to list before the server lists them.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
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..3f99642 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
 
@@ -154,19 +173,22 @@
 * `-Dcom.google.gerrit.scenarios.ssh_port=29418`
 * `-Dcom.google.gerrit.scenarios.http_port=8080`
 * `-Dcom.google.gerrit.scenarios.http_scheme=http`
+* `-Dcom.google.gerrit.scenarios.replication_delay=15`
 
 Above, the properties can be set with values matching specific deployment topologies under test.
 The example values shown above are the currently coded default ones. For example, the `http` scheme
-above could be replaced with `https`. The framework could support differing or more properties over
-time.
+above could be replaced with `https`. The `replication_delay` property matches replication plugin's
+configuration with the same name. The framework may support differing or more properties over time.
 
 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/gc-conductor[gc-conductor,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/high-availability[high-availability,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/multi-site[multi-site,role=external,window=_blank]`
+* `link:https://gerrit.googlesource.com/plugins/rename-project[rename-project,role=external,window=_blank]`
 
 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/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index adb5d20..91bc476 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -375,93 +375,4 @@
 Note: TODO
 
 === url
-`plugin.url(opt_path)`
-
-Note: TODO
-
-[[deprecated-api]]
-== Deprecated APIs
-
-Some of the deprecated APIs have limited implementation in PolyGerrit to serve
-as a "stepping stone" to allow gradual migration.
-
-=== install
-`plugin.deprecated.install()`
-
-.Params:
-- none
-
-Replaces plugin APIs with a deprecated version. This allows use of deprecated
-APIs without changing JS code. For example, `onAction` is not available by
-default, and after `plugin.deprecated.install()` it's accessible via
-`self.onAction()`.
-
-=== onAction
-`plugin.deprecated.onAction(type, view_name, callback)`
-
-.Params:
-- `*string* type` Action type.
-- `*string* view_name` REST API action.
-- `*function(actionContext)* callback` Callback invoked on action button click.
-
-Adds a button to the UI with a click callback. Exact button location depends on
-parameters. Callback is triggered with an instance of
-link:#deprecated-action-context[action context].
-
-Support is limited:
-
-- type is either `change` or `revision`.
-
-See link:js-api.html#self_onAction[self.onAction] for more info.
-
-=== panel
-`plugin.deprecated.panel(extensionpoint, callback)`
-
-.Params:
-- `*string* extensionpoint`
-- `*function(screenContext)* callback`
-
-Adds a UI DOM element and triggers a callback with context to allow direct DOM
-access.
-
-Support is limited:
-
-- extensionpoint is one of the following:
- * CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK
- * CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK
-
-See link:js-api.html#self_panel[self.panel] for more info.
-
-=== settingsScreen
-`plugin.deprecated.settingsScreent(path, menu, callback)`
-
-.Params:
-- `*string* path` URL path fragment of the screen for direct link.
-- `*string* menu` Menu item title.
-- `*function(settingsScreenContext)* callback`
-
-Adds a settings menu item and a section in the settings screen that is provided
-to plugin for setup.
-
-See link:js-api.html#self_settingsScreen[self.settingsScreen] for more info.
-
-[[deprecated-action-context]]
-=== Action Context (deprecated)
-Instance of Action Context is passed to `onAction()` callback.
-
-Support is limited:
-
-- `popup()`
-- `hide()`
-- `refresh()`
-- `textfield()`
-- `br()`
-- `msg()`
-- `div()`
-- `button()`
-- `checkbox()`
-- `label()`
-- `prependLabel()`
-- `call()`
-
-See link:js-api.html#ActionContext[Action Context] for more info.
+`plugin.url(opt_path)`
\ No newline at end of file
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
index bca4b7a..061c687 100644
--- a/Documentation/pg-plugin-migration.txt
+++ b/Documentation/pg-plugin-migration.txt
@@ -79,9 +79,6 @@
   <script>
     Gerrit.install(plugin => {
         // Setup block, is executed before sampleplugin.js
-
-        // Install deprecated JS APIs (onAction, popup, etc)
-        plugin.deprecated.install();
     });
   </script>
 
@@ -105,8 +102,6 @@
 - `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
 - setup script tag code is executed before `sampleplugin.js`
 - cleanup script tag code is executed after `sampleplugin.js`
-- `plugin.deprecated.install()` enables deprecated APIs (onAction(), popup(),
-etc) before `sampleplugin.js` is loaded
 
 This means the plugin instance is shared between .html-based and .js-based
 code. This allows to gradually and incrementally transfer code to the new API.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4227655..f2f5c15 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2039,6 +2039,10 @@
 comments for each path are sorted by patch set number. Each comment has
 the `patch_set` and `author` fields set.
 
+If the `enable_context` request parameter is set to true, the comment entries
+will contain a list of link:#context-line[ContextLine] containing the lines of
+the source file where the comment was written.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
@@ -5026,6 +5030,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
 --
@@ -6545,6 +6680,11 @@
 |`commit_id` |optional|
 Hex commit SHA1 (40 characters string) of the commit of the patchset to which
 this comment applies.
+|`context_lines` |optional|
+A list of link:#context-line[ContextLine] containing the lines of the source
+file where the comment was written. Available only if the "enable_context"
+parameter (see link:#list-change-comments[List Change Comments]) is set.
+
 |===========================
 
 [[comment-input]]
@@ -6616,6 +6756,18 @@
 |`end_character`     ||The character position in the end line. (0-based)
 |===========================
 
+[[context-line]]
+=== ContextLine
+The `ContextLine` entity contains the line number and line text of a single
+line of the source file content.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name          |Description
+|`line_number`       |The line number of the source line.
+|`context_line`      |String containing the line text.
+|===========================
+
 [[commit-info]]
 === CommitInfo
 The `CommitInfo` entity contains information about a commit.
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/CheckNewProjectReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
new file mode 100644
index 0000000..f15ddae
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
@@ -0,0 +1,6 @@
+[
+  {
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/_PROJECT",
+    "cmd": "clone"
+  }
+]
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/CheckNewProjectReplica1.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
new file mode 100644
index 0000000..61442fd
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
@@ -0,0 +1,58 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+
+import scala.concurrent.duration._
+
+class CheckNewProjectReplica1 extends GitSimulation {
+  private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+  private val default: String = name
+
+  private lazy val replicationDuration = replicationDelay + SecondsPerWeightUnit
+
+  override def relativeRuntimeWeight: Int = replicationDuration / SecondsPerWeightUnit + 2
+
+  override def replaceOverride(in: String): String = {
+    var next = replaceProperty("http_port1", 8081, in)
+    next = replaceKeyWith("_project", default, next)
+    super.replaceOverride(next)
+  }
+
+  private val test: ScenarioBuilder = scenario(unique)
+      .feed(data)
+      .exec(gitRequest)
+
+  private val createProject = new CreateProject(default)
+  private val deleteProject = new DeleteProject(default)
+
+  setUp(
+    createProject.test.inject(
+      nothingFor(stepWaitTime(createProject) seconds),
+      atOnceUsers(single)
+    ),
+    test.inject(
+      nothingFor(stepWaitTime(this) + replicationDuration seconds),
+      atOnceUsers(single)
+    ).protocols(gitProtocol),
+    deleteProject.test.inject(
+      nothingFor(stepWaitTime(deleteProject) seconds),
+      atOnceUsers(single)
+    ),
+  ).protocols(httpProtocol)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index 9f01e9f..14ada0d 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -44,10 +44,10 @@
     test.inject(
       nothingFor(stepWaitTime(this) seconds),
       constantUsersPerSec(single) during (duration seconds)
-    ),
+    ).protocols(gitProtocol),
     deleteProject.test.inject(
       nothingFor(stepWaitTime(deleteProject) + duration seconds),
       atOnceUsers(single)
     ),
-  ).protocols(gitProtocol, httpProtocol)
+  ).protocols(httpProtocol)
 }
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..860c7df 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
@@ -32,6 +32,7 @@
   protected val unique: String = name + "-" + this.hashCode()
   protected val single = 1
 
+  val replicationDelay: Int = replaceProperty("replication_delay", 15).toInt
   private val powerFactor: Double = replaceProperty("power_factor", 1.0).toDouble
   protected val SecondsPerWeightUnit: Int = 2
   val maxExecutionTime: Int = (SecondsPerWeightUnit * relativeRuntimeWeight * powerFactor).toInt
@@ -67,6 +68,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/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 1af2dc5..d529f48 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -51,11 +51,11 @@
       rampUsers(10) during (5 seconds),
       constantUsersPerSec(20) during (15 seconds),
       constantUsersPerSec(20) during (15 seconds) randomized
-    ),
+    ).protocols(gitProtocol),
     deleteProject.test.inject(
       nothingFor(maxBeforeDelete seconds),
       atOnceUsers(single)
     ),
-  ).protocols(gitProtocol, httpProtocol)
+  ).protocols(httpProtocol)
       .maxDuration(maxExecutionTime seconds)
 }
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/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 32bc992..452df67 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -48,7 +48,6 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
 import org.eclipse.jgit.junit.TestRepository;
@@ -98,7 +97,7 @@
 
   protected static class FakeEmailSenderSubject extends Subject {
     private final FakeEmailSender fakeEmailSender;
-    private Optional<Message> message;
+    private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
     private Set<String> accountedFor = new HashSet<>();
@@ -117,29 +116,35 @@
     }
 
     public FakeEmailSenderSubject sent(String messageType, StagedUsers users) {
-      fakeEmailSender.readOneMessage();
-      message =
-          fakeEmailSender.getMessages().stream()
-              .filter(
-                  m ->
-                      m.headers()
-                          .get("X-Gerrit-MessageType")
-                          .equals(new EmailHeader.String(messageType)))
-              .findFirst();
-      if (!message.isPresent()) {
-        failWithoutActual(
-            fact(String.format("expected message of type %s", messageType), "not sent"));
+      message = fakeEmailSender.nextMessage();
+      if (message == null) {
+        failWithoutActual(fact("expected message", "not sent"));
       }
       recipients = new HashMap<>();
-      recipients.put(TO, parseAddresses(message.get(), "To"));
-      recipients.put(CC, parseAddresses(message.get(), "Cc"));
+      recipients.put(TO, parseAddresses(message, "To"));
+      recipients.put(CC, parseAddresses(message, "Cc"));
       recipients.put(
           BCC,
-          message.get().rcpt().stream()
+          message.rcpt().stream()
               .map(Address::email)
               .filter(e -> !recipients.get(TO).contains(e) && !recipients.get(CC).contains(e))
               .collect(toList()));
       this.users = users;
+      if (!message.headers().containsKey("X-Gerrit-MessageType")) {
+        failWithoutActual(
+            fact("expected to have message sent with", "X-Gerrit-MessageType header"));
+      }
+      EmailHeader header = message.headers().get("X-Gerrit-MessageType");
+      if (!header.equals(new EmailHeader.String(messageType))) {
+        failWithoutActual(
+            fact("expected message of type", messageType),
+            fact(
+                "actual",
+                header instanceof EmailHeader.String
+                    ? ((EmailHeader.String) header).getString()
+                    : header));
+      }
+
       return this;
     }
 
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 f04de17..3b15b57 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -87,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(
@@ -102,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;
@@ -116,6 +118,7 @@
     this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
     this.perCommentOperationsFactory = perCommentOperationsFactory;
     this.perDraftCommentOperationsFactory = perDraftCommentOperationsFactory;
+    this.perRobotCommentOperationsFactory = perRobotCommentOperationsFactory;
   }
 
   @Override
@@ -555,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/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..b8c841c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -14,15 +14,13 @@
 
 package com.google.gerrit.acceptance.testsuite.change;
 
-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;
@@ -32,8 +30,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -58,7 +54,6 @@
   private final IdentifiedUser.GenericFactory userFactory;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final CommentsUtil commentsUtil;
-  private final PatchListCache patchListCache;
 
   private final ChangeNotes changeNotes;
   private final PatchSet.Id patchsetId;
@@ -73,14 +68,12 @@
       GenericFactory userFactory,
       BatchUpdate.Factory batchUpdateFactory,
       CommentsUtil commentsUtil,
-      PatchListCache patchListCache,
       @Assisted ChangeNotes changeNotes,
       @Assisted PatchSet.Id patchsetId) {
     this.repositoryManager = repositoryManager;
     this.userFactory = userFactory;
     this.batchUpdateFactory = batchUpdateFactory;
     this.commentsUtil = commentsUtil;
-    this.patchListCache = patchListCache;
     this.changeNotes = changeNotes;
     this.patchsetId = patchsetId;
   }
@@ -101,6 +94,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 +124,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;
@@ -135,7 +147,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext context) throws Exception {
+    public boolean updateChange(ChangeContext context) {
       HumanComment comment = toNewComment(context, commentCreation);
       ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
       changeUpdate.putComment(commentCreation.status(), comment);
@@ -146,19 +158,20 @@
       return true;
     }
 
-    private HumanComment toNewComment(ChangeContext context, TestCommentCreation commentCreation)
-        throws PatchListNotAvailableException {
+    private HumanComment toNewComment(ChangeContext context, TestCommentCreation commentCreation) {
       String message = commentCreation.message().orElse("The text of a test comment.");
 
       String filePath = commentCreation.file().orElse(Patch.PATCHSET_LEVEL);
       short side = commentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
       Boolean unresolved = commentCreation.unresolved().orElse(null);
       String parentUuid = commentCreation.parentUuid().orElse(null);
+      Timestamp createdOn =
+          commentCreation.createdOn().map(Timestamp::from).orElse(context.getWhen());
       HumanComment newComment =
           commentsUtil.newHumanComment(
               context.getNotes(),
               context.getUser(),
-              context.getWhen(),
+              createdOn,
               filePath,
               patchsetId,
               side,
@@ -173,24 +186,92 @@
       // Specification of range trumps explicit line specification.
       commentCreation
           .range()
-          .map(this::toCommentRange)
+          .map(PerPatchsetOperationsImpl::toCommentRange)
           .ifPresent(range -> newComment.setLineNbrAndRange(null, range));
 
-      setCommentCommitId(
-          newComment,
-          patchListCache,
-          context.getChange(),
-          changeNotes.getPatchSets().get(patchsetId));
+      commentsUtil.setCommentCommitId(
+          newComment, context.getChange(), 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) {
+      RobotComment robotComment = toNewRobotComment(context, robotCommentCreation);
+      ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
+      changeUpdate.putRobotComment(robotComment);
+      // For robot comments, only the tag set on the ChangeUpdate (and not on the RobotComment)
+      // matters.
+      robotCommentCreation.tag().ifPresent(changeUpdate::setTag);
+      createdRobotCommentUuid = robotComment.key.uuid;
+      return true;
+    }
+
+    private RobotComment toNewRobotComment(
+        ChangeContext context, TestRobotCommentCreation robotCommentCreation) {
+      String message = robotCommentCreation.message().orElse("The text of a test robot comment.");
+
+      String filePath = robotCommentCreation.file().orElse(Patch.PATCHSET_LEVEL);
+      short side = robotCommentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
+      String robotId = robotCommentCreation.robotId().orElse("robot");
+      String robotRunId = robotCommentCreation.robotId().orElse("1");
+      RobotComment newRobotComment =
+          commentsUtil.newRobotComment(
+              context, filePath, patchsetId, side, message, robotId, robotRunId);
+
+      // TODO(paiking): This should not be needed, as the tag only matters in ChangeUpdate.
+      robotCommentCreation.tag().ifPresent(tag -> newRobotComment.tag = tag);
+
+      robotCommentCreation.line().ifPresent(line -> newRobotComment.setLineNbrAndRange(line, null));
+      // Specification of range trumps explicit line specification.
+      robotCommentCreation
+          .range()
+          .map(PerPatchsetOperationsImpl::toCommentRange)
+          .ifPresent(range -> newRobotComment.setLineNbrAndRange(null, range));
+
+      robotCommentCreation
+          .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();
+      }
+
+      commentsUtil.setCommentCommitId(
+          newRobotComment, context.getChange(), changeNotes.getPatchSets().get(patchsetId));
+      return newRobotComment;
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java
new file mode 100644
index 0000000..c9718aa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+/** An aggregation of methods on a specific, robot comment. */
+public interface PerRobotCommentOperations {
+
+  /**
+   * Retrieves the robot comment.
+   *
+   * <p><strong>Note:</strong> This call will fail with an exception if the requested comment
+   * doesn't exist or if it is a comment of another type.
+   *
+   * @return the corresponding {@code TestRobotComment}
+   */
+  TestRobotComment get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java
new file mode 100644
index 0000000..075c451
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import static com.google.common.collect.MoreCollectors.onlyElement;
+
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerRobotCommentOperations}.
+ *
+ * <p>There is only one implementation of {@link PerRobotCommentOperations}. Nevertheless, we keep
+ * the separation between interface and implementation to enhance clarity.
+ */
+public class PerRobotCommentOperationsImpl implements PerRobotCommentOperations {
+  private final CommentsUtil commentsUtil;
+
+  private final ChangeNotes changeNotes;
+  private final String commentUuid;
+
+  public interface Factory {
+    PerRobotCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
+  }
+
+  @Inject
+  public PerRobotCommentOperationsImpl(
+      CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
+    this.commentsUtil = commentsUtil;
+    this.changeNotes = changeNotes;
+    this.commentUuid = commentUuid;
+  }
+
+  @Override
+  public TestRobotComment get() {
+    RobotComment comment =
+        commentsUtil.robotCommentsByChange(changeNotes).stream()
+            .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
+            .collect(onlyElement());
+    return toTestRobotComment(comment);
+  }
+
+  static TestRobotComment toTestRobotComment(RobotComment robotComment) {
+    return TestRobotComment.builder()
+        .uuid(robotComment.key.uuid)
+        .parentUuid(robotComment.parentUuid)
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java
new file mode 100644
index 0000000..b061c81
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import java.util.function.IntFunction;
+
+/**
+ * Builder to simplify a position specification. Used by {@link TestCommentCreation} and {@link
+ * TestRobotCommentCreation}.
+ */
+public class PositionBuilder<T> {
+  private final IntFunction<T> nextStepProvider;
+
+  public PositionBuilder(IntFunction<T> nextStepProvider) {
+    this.nextStepProvider = nextStepProvider;
+  }
+
+  /** Character offset within the line. A value of 0 indicates the beginning of the line. */
+  public T charOffset(int characterOffset) {
+    return nextStepProvider.apply(characterOffset);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java
new file mode 100644
index 0000000..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/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/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/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/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index e8b58f9..3b3a5fc 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -326,8 +326,12 @@
    * @return comments in a map keyed by path; comments have the {@code revision} field set to
    *     indicate their patch set.
    * @throws RestApiException
+   * @deprecated Callers should use {@link #commentsRequest()} instead
    */
-  Map<String, List<CommentInfo>> comments() throws RestApiException;
+  @Deprecated
+  default Map<String, List<CommentInfo>> comments() throws RestApiException {
+    return commentsRequest().get();
+  }
 
   /**
    * Get all published comments on a change as a list.
@@ -335,8 +339,21 @@
    * @return comments as a list; comments have the {@code revision} field set to indicate their
    *     patch set.
    * @throws RestApiException
+   * @deprecate Callers should use {@link #commentsRequest()} instead
    */
-  List<CommentInfo> commentsAsList() throws RestApiException;
+  @Deprecated
+  default List<CommentInfo> commentsAsList() throws RestApiException {
+    return commentsRequest().getAsList();
+  }
+
+  /**
+   * Get a {@link CommentsRequest} entity that can be used to retrieve published comments.
+   *
+   * @return A {@link CommentsRequest} entity that can be used to retrieve the comments using the
+   *     {@link CommentsRequest#get()} or {@link CommentsRequest#getAsList()}.
+   * @throws RestApiException
+   */
+  CommentsRequest commentsRequest() throws RestApiException;
 
   /**
    * Get all robot comments on a change.
@@ -395,6 +412,42 @@
    */
   ChangeMessageApi message(String id) throws RestApiException;
 
+  abstract class CommentsRequest {
+    private boolean enableContext;
+
+    /**
+     * Get all published comments on a change.
+     *
+     * @return comments in a map keyed by path; comments have the {@code revision} field set to
+     *     indicate their patch set.
+     * @throws RestApiException
+     */
+    public abstract Map<String, List<CommentInfo>> get() throws RestApiException;
+
+    /**
+     * Get all published comments on a change as a list.
+     *
+     * @return comments as a list; comments have the {@code revision} field set to indicate their
+     *     patch set.
+     * @throws RestApiException
+     */
+    public abstract List<CommentInfo> getAsList() throws RestApiException;
+
+    public CommentsRequest withContext(boolean enableContext) {
+      this.enableContext = enableContext;
+      return this;
+    }
+
+    public CommentsRequest withContext() {
+      this.enableContext = true;
+      return this;
+    }
+
+    public boolean getContext() {
+      return enableContext;
+    }
+  }
+
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
@@ -603,16 +656,23 @@
     }
 
     @Override
+    @Deprecated
     public Map<String, List<CommentInfo>> comments() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    @Deprecated
     public List<CommentInfo> commentsAsList() throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public CommentsRequest commentsRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/CommentInput.java b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
new file mode 100644
index 0000000..4e2f033
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+/** Input to the {@link ChangeApi#comments(CommentInput)}. */
+public class CommentInput {
+  public boolean enableContext;
+}
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index 19e002a..19d721b 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.client.Comment;
+import java.util.List;
 import java.util.Objects;
 
 public class CommentInfo extends Comment {
@@ -22,6 +23,12 @@
   public String tag;
   public String changeMessageId;
 
+  /**
+   * A list of {@link ContextLineInfo}, that is, a list of pairs of {line_num, line_text} of the
+   * actual source file content surrounding and including the lines where the comment was written.
+   */
+  public List<ContextLineInfo> contextLines;
+
   @Override
   public boolean equals(Object o) {
     if (super.equals(o)) {
diff --git a/java/com/google/gerrit/extensions/common/ContextLineInfo.java b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
new file mode 100644
index 0000000..3062e85
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.Objects;
+
+/**
+ * An entity class representing 1 line of context {line number, line text} of the source file where
+ * a comment was written.
+ */
+public class ContextLineInfo {
+  public int lineNumber;
+  public String contextLine;
+
+  public ContextLineInfo() {}
+
+  public ContextLineInfo(int lineNumber, String contextLine) {
+    this.lineNumber = lineNumber;
+    this.contextLine = contextLine;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ContextLineInfo) {
+      ContextLineInfo l = (ContextLineInfo) o;
+      return lineNumber == l.lineNumber && contextLine.equals(l.contextLine);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(lineNumber, contextLine);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/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/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
new file mode 100644
index 0000000..813dad7
--- /dev/null
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.Text;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
+ * source file surrounding and including the area where the comment was written.
+ */
+public class CommentContextLoader {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final Project.NameKey project;
+  private Map<ContextData, List<ContextLineInfo>> candidates;
+
+  public interface Factory {
+    CommentContextLoader create(Project.NameKey project);
+  }
+
+  @Inject
+  public CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
+    this.repoManager = repoManager;
+    this.project = project;
+    this.candidates = new HashMap<>();
+  }
+
+  /**
+   * Returns an empty list of {@link ContextLineInfo}. Clients are expected to call this method one
+   * or more times. Each call returns a reference to an empty {@link List<ContextLineInfo>}.
+   *
+   * <p>A single call to {@link #fill()} will cause all list references returned from this method to
+   * be populated. If a client calls this method again with a comment that was passed before calling
+   * {@link #fill()}, the new populated list will be returned.
+   *
+   * @param comment the comment entity for which we want to load the context
+   * @return a list of {@link ContextLineInfo}
+   */
+  public List<ContextLineInfo> getContext(CommentInfo comment) {
+    ContextData key =
+        ContextData.create(
+            comment.id,
+            ObjectId.fromString(comment.commitId),
+            comment.path,
+            getStartAndEndLines(comment));
+    List<ContextLineInfo> context = candidates.get(key);
+    if (context == null) {
+      context = new ArrayList<>();
+      candidates.put(key, context);
+    }
+    return context;
+  }
+
+  /**
+   * A call to this method loads the context for all comments stored in {@link
+   * CommentContextLoader#candidates}. This is useful so that the repository is opened once for all
+   * comments.
+   */
+  public void fill() {
+    // Group comments by commit ID so that each commit is parsed only once
+    Map<ObjectId, List<ContextData>> commentsByCommitId =
+        candidates.keySet().stream().collect(groupingBy(ContextData::commitId));
+
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      for (ObjectId commitId : commentsByCommitId.keySet()) {
+        RevCommit commit = rw.parseCommit(commitId);
+        for (ContextData k : commentsByCommitId.get(commitId)) {
+          if (!k.range().isPresent()) {
+            continue;
+          }
+          try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), k.path(), commit.getTree())) {
+            if (tw == null) {
+              logger.atWarning().log(
+                  "Failed to find path %s in the git tree of ID %s.",
+                  k.path(), commit.getTree().getId());
+              continue;
+            }
+            ObjectId id = tw.getObjectId(0);
+            Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
+            List<ContextLineInfo> contextLines = candidates.get(k);
+            Range r = k.range().get();
+            for (int i = r.start(); i <= r.end(); i++) {
+              contextLines.add(new ContextLineInfo(i, src.getString(i - 1)));
+            }
+          }
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException("Failed to load the comment context", e);
+    }
+  }
+
+  private static Optional<Range> getStartAndEndLines(CommentInfo comment) {
+    if (comment.range != null) {
+      return Optional.of(Range.create(comment.range.startLine, comment.range.endLine));
+    } else if (comment.line != null) {
+      return Optional.of(Range.create(comment.line, comment.line));
+    }
+    return Optional.empty();
+  }
+
+  @AutoValue
+  abstract static class Range {
+    static Range create(int start, int end) {
+      return new AutoValue_CommentContextLoader_Range(start, end);
+    }
+
+    abstract int start();
+
+    abstract int end();
+  }
+
+  @AutoValue
+  abstract static class ContextData {
+    static ContextData create(String id, ObjectId commitId, String path, Optional<Range> range) {
+      return new AutoValue_CommentContextLoader_ContextData(id, commitId, path, range);
+    }
+
+    abstract String id();
+
+    abstract ObjectId commitId();
+
+    abstract String path();
+
+    abstract Optional<Range> range();
+  }
+}
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 30913f7..b752791 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;
@@ -32,6 +31,7 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
@@ -51,15 +51,12 @@
 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;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /** Utility functions to manipulate Comments. */
 @Singleton
@@ -114,13 +111,18 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final String serverId;
+  private final PatchListCache patchListCache;
 
   @Inject
   CommentsUtil(
-      GitRepositoryManager repoManager, AllUsersName allUsers, @GerritServerId String serverId) {
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      @GerritServerId String serverId,
+      PatchListCache patchListCache) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.serverId = serverId;
+    this.patchListCache = patchListCache;
   }
 
   public HumanComment newHumanComment(
@@ -189,6 +191,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))
@@ -205,6 +213,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())) {
@@ -344,43 +356,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());
@@ -404,41 +379,63 @@
     return sort(result);
   }
 
-  public static void setCommentCommitId(Comment c, PatchListCache cache, Change change, PatchSet ps)
-      throws PatchListNotAvailableException {
+  public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
     checkArgument(
         c.key.patchSetId == ps.id().get(),
         "cannot set commit ID for patch set %s on comment %s",
         ps.id(),
         c);
     if (c.getCommitId() == null) {
-      c.setCommitId(determineCommitId(cache, change, ps, c.side));
+      // This code is very much down into our stack and shouldn't be used for validation. Hence,
+      // don't throw an exception here if we can't find a commit for the indicated side but
+      // simply use the all-null ObjectId.
+      c.setCommitId(determineCommitId(change, ps, c.side).orElseGet(ObjectId::zeroId));
     }
   }
 
   /**
    * Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
    *
-   * @param 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
+   * @return the commit SHA-1 or an empty {@link Optional} if the side isn't available for the given
+   *     change/patchset
+   * @throws StorageException if the SHA-1 is unavailable for an unknown reason
    */
-  public static ObjectId determineCommitId(
-      PatchListCache patchListCache, Change change, PatchSet patchset, short side)
-      throws PatchListNotAvailableException {
+  public Optional<ObjectId> determineCommitId(Change change, PatchSet patchset, short side) {
     if (Side.fromShort(side) == Side.PARENT) {
       if (side < 0) {
-        return patchListCache.getOldId(change, patchset, -side);
-      } else {
-        return patchListCache.getOldId(change, patchset, null);
+        int parentNumber = Math.abs(side);
+        return resolveParentCommit(change.getProject(), patchset, parentNumber);
       }
-    } else {
-      return patchset.commitId();
+      return Optional.of(resolveAutoMergeCommit(change, patchset));
+    }
+    return Optional.of(patchset.commitId());
+  }
+
+  private Optional<ObjectId> resolveParentCommit(
+      Project.NameKey project, PatchSet patchset, int parentNumber) {
+    try (Repository repository = repoManager.openRepository(project)) {
+      RevCommit commit = repository.parseCommit(patchset.commitId());
+      if (commit.getParentCount() < parentNumber) {
+        return Optional.empty();
+      }
+      return Optional.of(commit.getParent(parentNumber - 1));
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
+    try {
+      // TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
+      // unignore the test in PortedCommentsIT.
+      return patchListCache.getOldId(change, patchset, null);
+    } catch (PatchListNotAvailableException e) {
+      throw new StorageException(e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 658af15..4d19dd0 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -22,15 +22,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
@@ -44,16 +41,13 @@
 public class PublishCommentUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final CommentsUtil commentsUtil;
 
   @Inject
-  PublishCommentUtil(
-      CommentsUtil commentsUtil, PatchListCache patchListCache, PatchSetUtil psUtil) {
+  PublishCommentUtil(CommentsUtil commentsUtil, PatchSetUtil psUtil) {
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   public void publish(
@@ -101,11 +95,7 @@
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
       ctx.getUser().updateRealAccountId(draftComment::setRealAuthor);
-      try {
-        CommentsUtil.setCommentCommitId(draftComment, patchListCache, notes.getChange(), ps);
-      } catch (PatchListNotAvailableException e) {
-        throw new StorageException(e);
-      }
+      commentsUtil.setCommentCommitId(draftComment, notes.getChange(), ps);
       commentsToPublish.add(draftComment);
     }
     commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, commentsToPublish);
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index e03c244..358ce92 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
@@ -47,7 +46,7 @@
  * <p>This class uses the {@link PublishCommentUtil} to publish draft comments and fires the
  * necessary event for this.
  */
-public class PublishCommentsOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class PublishCommentsOp implements BatchUpdateOp {
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeMessagesUtil cmUtil;
@@ -62,8 +61,6 @@
   private List<HumanComment> comments = new ArrayList<>();
   private ChangeMessage message;
   private IdentifiedUser user;
-  private ChangeNotes changeNotes;
-  private PatchSet patchset;
 
   public interface Factory {
     PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
@@ -112,24 +109,11 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
-    patchset = psUtil.get(changeNotes, psId);
-
-    commentAdded.fire(
-        changeNotes.getChange(),
-        patchset,
-        ctx.getAccount(),
-        message.getMessage(),
-        ImmutableMap.of(),
-        ImmutableMap.of(),
-        ctx.getWhen());
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
     if (message == null || comments.isEmpty()) {
       return;
     }
+    ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
+    PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
       RepoView repoView;
@@ -140,10 +124,17 @@
             String.format("Repository %s not found", ctx.getProject().get()), ex);
       }
       email
-          .create(
-              notify, changeNotes, patchset, user, message, comments, null, labelDelta, repoView)
-          .send();
+          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .sendAsync();
     }
+    commentAdded.fire(
+        changeNotes.getChange(),
+        ps,
+        ctx.getAccount(),
+        message.getMessage(),
+        ImmutableMap.of(),
+        ImmutableMap.of(),
+        ctx.getWhen());
   }
 
   private boolean insertMessage(ChangeContext ctx, ChangeUpdate changeUpdate) {
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index b4a5da7..0992bcd 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -158,7 +158,7 @@
   private final GetAssignee getAssignee;
   private final GetPastAssignees getPastAssignees;
   private final DeleteAssignee deleteAssignee;
-  private final ListChangeComments listComments;
+  private final Provider<ListChangeComments> listCommentsProvider;
   private final ListChangeRobotComments listChangeRobotComments;
   private final ListChangeDrafts listDrafts;
   private final ChangeEditApiImpl.Factory changeEditApi;
@@ -211,7 +211,7 @@
       GetAssignee getAssignee,
       GetPastAssignees getPastAssignees,
       DeleteAssignee deleteAssignee,
-      ListChangeComments listComments,
+      Provider<ListChangeComments> listCommentsProvider,
       ListChangeRobotComments listChangeRobotComments,
       ListChangeDrafts listDrafts,
       ChangeEditApiImpl.Factory changeEditApi,
@@ -262,7 +262,7 @@
     this.getAssignee = getAssignee;
     this.getPastAssignees = getPastAssignees;
     this.deleteAssignee = deleteAssignee;
-    this.listComments = listComments;
+    this.listCommentsProvider = listCommentsProvider;
     this.listChangeRobotComments = listChangeRobotComments;
     this.listDrafts = listDrafts;
     this.changeEditApi = changeEditApi;
@@ -599,21 +599,30 @@
   }
 
   @Override
-  public Map<String, List<CommentInfo>> comments() throws RestApiException {
-    try {
-      return listComments.apply(change).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get comments", e);
-    }
-  }
+  public CommentsRequest commentsRequest() throws RestApiException {
+    return new CommentsRequest() {
+      @Override
+      public Map<String, List<CommentInfo>> get() throws RestApiException {
+        try {
+          ListChangeComments listComments = listCommentsProvider.get();
+          listComments.setContext(this.getContext());
+          return listComments.apply(change).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get comments", e);
+        }
+      }
 
-  @Override
-  public List<CommentInfo> commentsAsList() throws RestApiException {
-    try {
-      return listComments.getComments(change);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get comments", e);
-    }
+      @Override
+      public List<CommentInfo> getAsList() throws RestApiException {
+        try {
+          ListChangeComments listComments = listCommentsProvider.get();
+          listComments.setContext(this.getContext());
+          return listComments.getComments(change);
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get comments", e);
+        }
+      }
+    };
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index e0e489b..6c39ed0 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -30,14 +30,13 @@
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
-public class AbandonOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class AbandonOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AbandonedSender.Factory abandonedSenderFactory;
@@ -52,7 +51,6 @@
   private Change change;
   private PatchSet patchSet;
   private ChangeMessage message;
-  private NotifyResolver.Result notify;
 
   public interface Factory {
     AbandonOp create(
@@ -98,7 +96,6 @@
     update.setStatus(change.getStatus());
     message = newMessage(ctx);
     cmUtil.addChangeMessage(update, message);
-    notify = ctx.getNotify(change.getId());
     return true;
   }
 
@@ -115,11 +112,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
-    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
       ReplyToChangeSender emailSender =
           abandonedSenderFactory.create(ctx.getProject(), change.getId());
@@ -134,5 +127,6 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
+    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index 5e48353..4a3f638 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -23,27 +23,34 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
 @Singleton
 public class AddReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   AddReviewersEmail(
-      AddReviewerSender.Factory addReviewerSenderFactory, MessageIdGenerator messageIdGenerator) {
+      AddReviewerSender.Factory addReviewerSenderFactory,
+      @SendEmailExecutor ExecutorService sendEmailsExecutor,
+      MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.sendEmailsExecutor = sendEmailsExecutor;
     this.messageIdGenerator = messageIdGenerator;
   }
 
-  public void emailReviewers(
+  public void emailReviewersAsync(
       IdentifiedUser user,
       Change change,
       Collection<Account.Id> added,
@@ -71,20 +78,27 @@
     ImmutableList<Address> immutableAddedByEmail = ImmutableList.copyOf(addedByEmail);
     ImmutableList<Address> immutableCopiedByEmail = ImmutableList.copyOf(copiedByEmail);
 
-    try {
-      AddReviewerSender emailSender = addReviewerSenderFactory.create(projectNameKey, cId);
-      emailSender.setNotify(notify);
-      emailSender.setFrom(userId);
-      emailSender.addReviewers(immutableToMail);
-      emailSender.addReviewersByEmail(immutableAddedByEmail);
-      emailSender.addExtraCC(immutableToCopy);
-      emailSender.addExtraCCByEmail(immutableCopiedByEmail);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(change.getProject(), change.currentPatchSetId()));
-      emailSender.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot send email to new reviewers of change %s", change.getId());
-    }
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailsExecutor.submit(
+            () -> {
+              try {
+                AddReviewerSender emailSender =
+                    addReviewerSenderFactory.create(projectNameKey, cId);
+                emailSender.setNotify(notify);
+                emailSender.setFrom(userId);
+                emailSender.addReviewers(immutableToMail);
+                emailSender.addReviewersByEmail(immutableAddedByEmail);
+                emailSender.addExtraCC(immutableToCopy);
+                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(
+                        change.getProject(), change.currentPatchSetId()));
+                emailSender.send();
+              } catch (Exception err) {
+                logger.atSevere().withCause(err).log(
+                    "Cannot send email to new reviewers of change %s", change.getId());
+              }
+            });
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index fd57722..ff8e5c6 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -53,7 +52,7 @@
 import java.util.List;
 import java.util.Set;
 
-public class AddReviewersOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class AddReviewersOp implements BatchUpdateOp {
   public interface Factory {
 
     /**
@@ -239,7 +238,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(Context ctx) throws Exception {
     opResult =
         Result.builder()
             .setAddedReviewers(addedReviewers)
@@ -247,20 +246,8 @@
             .setAddedCCs(addedCCs)
             .setAddedCCsByEmail(addedCCsByEmail)
             .build();
-    if (!addedReviewers.isEmpty()) {
-      List<AccountState> reviewers =
-          addedReviewers.stream()
-              .map(r -> accountCache.get(r.accountId()))
-              .flatMap(Streams::stream)
-              .collect(toList());
-      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-    }
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
     if (sendEmail) {
-      addReviewersEmail.emailReviewers(
+      addReviewersEmail.emailReviewersAsync(
           ctx.getUser().asIdentifiedUser(),
           change,
           Lists.transform(addedReviewers, PatchSetApproval::accountId),
@@ -269,6 +256,14 @@
           addedCCsByEmail,
           ctx.getNotify(change.getId()));
     }
+    if (!addedReviewers.isEmpty()) {
+      List<AccountState> reviewers =
+          addedReviewers.stream()
+              .map(r -> accountCache.get(r.accountId()))
+              .flatMap(Streams::stream)
+              .collect(toList());
+      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
   }
 
   public Result getResult() {
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 31147fc..8053b30 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -35,7 +34,7 @@
 import java.io.IOException;
 
 /** Add a specified user to the attention set. */
-public class AddToAttentionSetOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class AddToAttentionSetOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -102,7 +101,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     if (!notify) {
       return;
     }
@@ -115,7 +114,7 @@
               reason,
               messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
               attentionUserId)
-          .send();
+          .sendAsync();
     } catch (IOException e) {
       logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 2f95a5c..a086cb1 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
@@ -68,12 +69,12 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.NoSshInfo;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.InsertChangeOp;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -83,13 +84,15 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class ChangeInserter implements InsertChangeOp, AsyncPostUpdateOp {
+public class ChangeInserter implements InsertChangeOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -103,6 +106,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CreateChangeSender.Factory createChangeSenderFactory;
+  private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
@@ -126,6 +130,7 @@
   private List<String> groups = Collections.emptyList();
   private boolean validate = true;
   private Map<String, Short> approvals;
+  private RequestScopePropagator requestScopePropagator;
   private boolean fireRevisionCreated;
   private boolean sendMail;
   private boolean updateRef;
@@ -141,7 +146,6 @@
   private String pushCert;
   private ProjectState projectState;
   private ReviewerAdditionList reviewerAdditions;
-  private NotifyResolver.Result notify;
 
   @Inject
   ChangeInserter(
@@ -152,6 +156,7 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CreateChangeSender.Factory createChangeSenderFactory,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
@@ -168,6 +173,7 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.createChangeSenderFactory = createChangeSenderFactory;
+    this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
@@ -309,6 +315,11 @@
     return this;
   }
 
+  public ChangeInserter setRequestScopePropagator(RequestScopePropagator r) {
+    this.requestScopePropagator = r;
+    return this;
+  }
+
   public ChangeInserter setRevertOf(Change.Id revertOf) {
     this.revertOf = revertOf;
     return this;
@@ -446,13 +457,57 @@
               ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
       cmUtil.addChangeMessage(update, changeMessage);
     }
-    notify = ctx.getNotify(change.getId());
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(Context ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
+    if (sendMail && notify.shouldNotify()) {
+      Runnable sender =
+          new Runnable() {
+            @Override
+            public void run() {
+              try {
+                CreateChangeSender emailSender =
+                    createChangeSenderFactory.create(change.getProject(), change.getId());
+                emailSender.setFrom(change.getOwner());
+                emailSender.setPatchSet(patchSet, patchSetInfo);
+                emailSender.setNotify(notify);
+                emailSender.addReviewers(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId)
+                        .collect(toImmutableSet()));
+                emailSender.addReviewersByEmail(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
+                emailSender.addExtraCC(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                emailSender.addExtraCCByEmail(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                emailSender.send();
+              } catch (Exception e) {
+                logger.atSevere().withCause(e).log(
+                    "Cannot send email for new change %s", change.getId());
+              }
+            }
+
+            @Override
+            public String toString() {
+              return "send-email newchange";
+            }
+          };
+      if (requestScopePropagator != null) {
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError =
+            sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
+      } else {
+        sender.run();
+      }
+    }
+
     /* For labels that are not set in this operation, show the "current" value
      * of 0, and no oldValue as the value was not modified by this operation.
      * For labels that are set in this operation, the value was modified, so
@@ -480,34 +535,6 @@
     }
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    reviewerAdditions.asyncPostUpdate(ctx);
-    if (sendMail && notify.shouldNotify()) {
-      try {
-        CreateChangeSender emailSender =
-            createChangeSenderFactory.create(change.getProject(), change.getId());
-        emailSender.setFrom(change.getOwner());
-        emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setNotify(notify);
-        emailSender.addReviewers(
-            reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                .map(PatchSetApproval::accountId)
-                .collect(toImmutableSet()));
-        emailSender.addReviewersByEmail(
-            reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
-        emailSender.addExtraCC(reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
-        emailSender.addExtraCCByEmail(
-            reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-        emailSender.send();
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Cannot send email for new change %s", change.getId());
-      }
-    }
-  }
-
   private void validate(RepoContext ctx) throws IOException, ResourceConflictException {
     if (!validate) {
       return;
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/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index d9ede58..255e13a 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -30,7 +29,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collections;
 
-public class DeleteReviewerByEmailOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -74,7 +73,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     try {
       NotifyResolver.Result notify = ctx.getNotify(change.getId());
       if (!notify.shouldNotify()) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index de11e07..07cb04f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -56,7 +55,7 @@
 import java.util.HashMap;
 import java.util.Map;
 
-public class DeleteReviewerOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class DeleteReviewerOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -77,12 +76,11 @@
   private final AccountState reviewer;
   private final DeleteReviewerInput input;
 
-  private ChangeMessage changeMessage;
-  private Change currChange;
-  private PatchSet currPs;
-  private Map<String, Short> newApprovals = new HashMap<>();
-  private Map<String, Short> oldApprovals = new HashMap<>();
-  private NotifyResolver.Result notify;
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
 
   @Inject
   DeleteReviewerOp(
@@ -167,27 +165,13 @@
     changeMessage =
         ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     cmUtil.addChangeMessage(update, changeMessage);
-    notify = ctx.getNotify(currChange.getId());
 
     return true;
   }
 
   @Override
   public void postUpdate(Context ctx) {
-    reviewerDeleted.fire(
-        currChange,
-        currPs,
-        reviewer,
-        ctx.getAccount(),
-        changeMessage.getMessage(),
-        newApprovals,
-        oldApprovals,
-        notify.handling(),
-        ctx.getWhen());
-  }
-
-  @Override
-  public void asyncPostUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(currChange.getId());
     if (input.notify == null
         && currChange.isWorkInProgress()
         && !oldApprovals.isEmpty()
@@ -203,6 +187,16 @@
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
     }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        notify.handling(),
+        ctx.getWhen());
   }
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
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/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 309b041..cacfbe7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -21,18 +21,24 @@
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
-public class EmailReviewComments {
+public class EmailReviewComments implements Runnable, RequestContext {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -65,8 +71,10 @@
         RepoView repoView);
   }
 
+  private final ExecutorService sendEmailsExecutor;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommentSender.Factory commentSenderFactory;
+  private final ThreadLocalRequestContext requestContext;
   private final MessageIdGenerator messageIdGenerator;
 
   private final NotifyResolver.Result notify;
@@ -81,8 +89,10 @@
 
   @Inject
   EmailReviewComments(
+      @SendEmailExecutor ExecutorService executor,
       PatchSetInfoFactory patchSetInfoFactory,
       CommentSender.Factory commentSenderFactory,
+      ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted NotifyResolver.Result notify,
       @Assisted ChangeNotes notes,
@@ -93,8 +103,10 @@
       @Nullable @Assisted String patchSetComment,
       @Assisted List<LabelVote> labels,
       @Assisted RepoView repoView) {
+    this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
+    this.requestContext = requestContext;
     this.messageIdGenerator = messageIdGenerator;
     this.notify = notify;
     this.notes = notes;
@@ -107,7 +119,14 @@
     this.repoView = repoView;
   }
 
-  public void send() {
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
+    RequestContext old = requestContext.setContext(this);
     try {
       CommentSender emailSender =
           commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
@@ -124,6 +143,18 @@
       emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
+    } finally {
+      requestContext.setContext(old);
     }
   }
+
+  @Override
+  public String toString() {
+    return "send-email comments";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    return user.getRealUser();
+  }
 }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 4502408..882352d 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -49,7 +49,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.NoSshInfo;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -63,7 +62,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class PatchSetInserter implements BatchUpdateOp, AsyncPostUpdateOp {
+public class PatchSetInserter implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -110,7 +109,6 @@
   private ChangeMessage changeMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
-  private NotifyResolver.Result notify;
 
   @Inject
   public PatchSetInserter(
@@ -281,12 +279,12 @@
         throw new BadRequestException(ex.getMessage());
       }
     }
-    notify = ctx.getNotify(change.getId());
     return true;
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
+    NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(changeMessage);
       try {
@@ -306,10 +304,7 @@
             "Cannot send email for new patch set on change %s", change.getId());
       }
     }
-  }
 
-  @Override
-  public void postUpdate(Context ctx) {
     if (fireRevisionCreated) {
       revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
     }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a8cc75a..231359b 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -49,7 +48,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
-public class RebaseChangeOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
   }
@@ -222,11 +221,6 @@
     patchSetInserter.postUpdate(ctx);
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    patchSetInserter.asyncPostUpdate(ctx);
-  }
-
   public RevCommit getRebasedCommit() {
     checkState(rebasedCommit != null, "getRebasedCommit() only valid after updateRepo");
     return rebasedCommit;
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 7f4e0e3..e532409 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -37,7 +36,7 @@
 import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
-public class RemoveFromAttentionSetOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class RemoveFromAttentionSetOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -102,7 +101,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     if (!notify) {
       return;
     }
@@ -115,7 +114,7 @@
               reason,
               messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
               attentionUserId)
-          .send();
+          .sendAsync();
     } catch (IOException e) {
       logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index 3638bf2..3d986d2 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -587,7 +587,7 @@
       }
     }
 
-    public void postUpdate(Context ctx) {
+    public void postUpdate(Context ctx) throws Exception {
       for (ReviewerAddition addition : additions()) {
         if (addition.op != null) {
           addition.op.postUpdate(ctx);
@@ -595,14 +595,6 @@
       }
     }
 
-    public void asyncPostUpdate(Context ctx) {
-      for (ReviewerAddition addition : additions()) {
-        if (addition.op != null) {
-          addition.op.asyncPostUpdate(ctx);
-        }
-      }
-    }
-
     public <T> ImmutableSet<T> flattenResults(
         Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
       additions()
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index c2e36f5..411c9b6 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -38,7 +37,7 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 
-public class SetAssigneeOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class SetAssigneeOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -121,7 +120,7 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
     try {
       SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
@@ -134,10 +133,6 @@
       logger.atSevere().withCause(err).log(
           "Cannot send email to new assignee of change %s", change.getId());
     }
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
     assigneeChanged.fire(
         change, ctx.getAccount(), oldAssignee != null ? oldAssignee.state() : null, ctx.getWhen());
   }
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 99f7adb..f0ebb80 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -38,7 +37,7 @@
 import java.io.IOException;
 
 /* Set work in progress or ready for review state on a change */
-public class WorkInProgressOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class WorkInProgressOp implements BatchUpdateOp {
   public static class Input extends InputWithMessage {
     @Nullable public NotifyHandling notify;
 
@@ -127,7 +126,8 @@
   }
 
   @Override
-  public void asyncPostUpdate(Context ctx) {
+  public void postUpdate(Context ctx) {
+    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (workInProgress
         || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
@@ -152,11 +152,6 @@
             cmsg.getMessage(),
             ImmutableList.of(),
             repoView)
-        .send();
-  }
-
-  @Override
-  public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/AsyncPostUpdateExecutor.java b/java/com/google/gerrit/server/config/SendEmailExecutor.java
similarity index 90%
rename from java/com/google/gerrit/server/config/AsyncPostUpdateExecutor.java
rename to java/com/google/gerrit/server/config/SendEmailExecutor.java
index 7951b8c..cf90cbf 100644
--- a/java/com/google/gerrit/server/config/AsyncPostUpdateExecutor.java
+++ b/java/com/google/gerrit/server/config/SendEmailExecutor.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2020 The Android Open Source Project
+// Copyright (C) 2014 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -23,4 +23,4 @@
 /** Marker on the global {@link ScheduledThreadPoolExecutor} used to send email. */
 @Retention(RUNTIME)
 @BindingAnnotation
-public @interface AsyncPostUpdateExecutor {}
+public @interface SendEmailExecutor {}
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index 5caf6fe..ea45b12 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -51,20 +51,14 @@
 
   @Provides
   @Singleton
-  @AsyncPostUpdateExecutor
+  @SendEmailExecutor
   public ExecutorService provideSendEmailExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
-    // sendemail.threadPoolSize is deprecated and overridden by asyncPostUpdate.threadPoolSize.
-    int poolSize =
-        config.getInt(
-            "asyncPostUpdate",
-            null,
-            "threadPoolSize",
-            config.getInt("sendemail", null, "threadPoolSize", 1));
+    int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
     if (poolSize == 0) {
       return newDirectExecutorService();
     }
-    return queues.createQueue(poolSize, "AsyncPostUpdate", true);
+    return queues.createQueue(poolSize, "SendEmail", true);
   }
 
   @Provides
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 67c7f01..47cbd60 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -301,7 +300,7 @@
     return changeId;
   }
 
-  private class NotifyOp implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final ChangeInserter ins;
 
@@ -311,12 +310,8 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(change, ins.getChange(), ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 13625bc..40e2730 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -25,19 +25,22 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -49,21 +52,24 @@
  * <p>When we find a change corresponding to a commit that is pushed to a branch directly, we close
  * the change. This class marks the change as merged, and sends out the email notification.
  */
-public class MergedByPushOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class MergedByPushOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
     MergedByPushOp create(
+        RequestScopePropagator requestScopePropagator,
         PatchSet.Id psId,
         @Assisted SubmissionId submissionId,
         @Assisted("refName") String refName,
         @Assisted("mergeResultRevId") String mergeResultRevId);
   }
 
+  private final RequestScopePropagator requestScopePropagator;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final ChangeMessagesUtil cmUtil;
   private final MergedSender.Factory mergedSenderFactory;
   private final PatchSetUtil psUtil;
+  private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
   private final MessageIdGenerator messageIdGenerator;
 
@@ -84,8 +90,10 @@
       ChangeMessagesUtil cmUtil,
       MergedSender.Factory mergedSenderFactory,
       PatchSetUtil psUtil,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
       MessageIdGenerator messageIdGenerator,
+      @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
       @Assisted SubmissionId submissionId,
       @Assisted("refName") String refName,
@@ -94,8 +102,10 @@
     this.cmUtil = cmUtil;
     this.mergedSenderFactory = mergedSenderFactory;
     this.psUtil = psUtil;
+    this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
     this.messageIdGenerator = messageIdGenerator;
+    this.requestScopePropagator = requestScopePropagator;
     this.submissionId = submissionId;
     this.psId = psId;
     this.refName = refName;
@@ -171,27 +181,36 @@
     if (!correctBranch) {
       return;
     }
+    @SuppressWarnings("unused") // Runnable already handles errors
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator.wrap(
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    try {
+                      MergedSender emailSender =
+                          mergedSenderFactory.create(ctx.getProject(), psId.changeId());
+                      emailSender.setFrom(ctx.getAccountId());
+                      emailSender.setPatchSet(patchSet, info);
+                      emailSender.setMessageId(
+                          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                      emailSender.send();
+                    } catch (Exception e) {
+                      logger.atSevere().withCause(e).log(
+                          "Cannot send email for submitted patch set %s", psId);
+                    }
+                  }
+
+                  @Override
+                  public String toString() {
+                    return "send-email merged";
+                  }
+                }));
+
     changeMerged.fire(change, patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    if (!correctBranch) {
-      return;
-    }
-
-    try {
-      MergedSender emailSender = mergedSenderFactory.create(ctx.getProject(), psId.changeId());
-      emailSender.setFrom(ctx.getAccountId());
-      emailSender.setPatchSet(patchSet, info);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-      emailSender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot send email for submitted patch set %s", psId);
-    }
-  }
-
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
     RevWalk rw = ctx.getRevWalk();
     RevCommit commit = rw.parseCommit(requireNonNull(patchSet).commitId());
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index e44f422..015ed0b 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -175,6 +175,7 @@
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.util.cli.CmdLineParser;
@@ -338,6 +339,7 @@
   private final PluginSetContext<RequestListener> requestListeners;
   private final PublishCommentsOp.Factory publishCommentsOp;
   private final RetryHelper retryHelper;
+  private final RequestScopePropagator requestScopePropagator;
   private final Sequences seq;
   private final SetHashtagsOp.Factory hashtagsFactory;
   private final SubmoduleOp.Factory subOpFactory;
@@ -419,6 +421,7 @@
       ReplaceOp.Factory replaceOpFactory,
       PluginSetContext<RequestListener> requestListeners,
       RetryHelper retryHelper,
+      RequestScopePropagator requestScopePropagator,
       Sequences seq,
       SetHashtagsOp.Factory hashtagsFactory,
       SubmoduleOp.Factory subOpFactory,
@@ -467,6 +470,7 @@
     this.replaceOpFactory = replaceOpFactory;
     this.requestListeners = requestListeners;
     this.retryHelper = retryHelper;
+    this.requestScopePropagator = requestScopePropagator;
     this.seq = seq;
     this.subOpFactory = subOpFactory;
     this.tagCache = tagCache;
@@ -2582,6 +2586,7 @@
                       magicBranch.getCombinedCcs(fromFooters))
                   .setApprovals(approvals)
                   .setMessage(msg.toString())
+                  .setRequestScopePropagator(requestScopePropagator)
                   .setSendMail(true)
                   .setPatchSetDescription(magicBranch.message));
           if (!magicBranch.hashtags.isEmpty()) {
@@ -3008,20 +3013,22 @@
 
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
         replaceOp =
-            replaceOpFactory.create(
-                projectState,
-                notes.getChange().getDest(),
-                checkMergedInto,
-                checkMergedInto ? inputCommand.getNewId().name() : null,
-                priorPatchSet,
-                priorCommit,
-                psId,
-                newCommit,
-                info,
-                groups,
-                magicBranch,
-                receivePack.getPushCertificate(),
-                notes.getChange());
+            replaceOpFactory
+                .create(
+                    projectState,
+                    notes.getChange().getDest(),
+                    checkMergedInto,
+                    checkMergedInto ? inputCommand.getNewId().name() : null,
+                    priorPatchSet,
+                    priorCommit,
+                    psId,
+                    newCommit,
+                    info,
+                    groups,
+                    magicBranch,
+                    receivePack.getPushCertificate(),
+                    notes.getChange())
+                .setRequestScopePropagator(requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -3302,7 +3309,11 @@
                           bu.addOp(
                               psId.changeId(),
                               mergedByPushOpFactory.create(
-                                  psId, submissionId, refName, newTip.getId().getName()));
+                                  requestScopePropagator,
+                                  psId,
+                                  submissionId,
+                                  refName,
+                                  newTip.getId().getName()));
                           continue COMMIT;
                         }
                       }
@@ -3346,7 +3357,12 @@
                       bu.addOp(
                           id,
                           mergedByPushOpFactory
-                              .create(req.psId, submissionId, refName, newTip.getId().getName())
+                              .create(
+                                  requestScopePropagator,
+                                  req.psId,
+                                  submissionId,
+                                  refName,
+                                  newTip.getId().getName())
                               .setPatchSetProvider(req.replaceOp::getPatchSet));
                       bu.addOp(id, new ChangeProgressOp(progress));
                       ids.add(id);
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 87266f3..ce62d7a 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
 import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
@@ -70,11 +71,11 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -85,6 +86,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -93,7 +96,7 @@
 import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class ReplaceOp implements BatchUpdateOp, AsyncPostUpdateOp {
+public class ReplaceOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -120,6 +123,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
+  private final ExecutorService sendEmailExecutor;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
@@ -153,6 +157,7 @@
   private ChangeMessage msg;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
+  private RequestScopePropagator requestScopePropagator;
   private ReviewerAdditionList reviewerAdditions;
   private MailRecipients oldRecipients;
 
@@ -169,6 +174,7 @@
       PatchSetUtil psUtil,
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       Change change,
       MessageIdGenerator messageIdGenerator,
@@ -196,6 +202,7 @@
     this.psUtil = psUtil;
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
+    this.sendEmailExecutor = sendEmailExecutor;
     this.reviewerAdder = reviewerAdder;
     this.change = change;
     this.messageIdGenerator = messageIdGenerator;
@@ -232,7 +239,11 @@
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
-                patchSetId, new SubmissionId(change), mergedInto, mergeResultRevId);
+                requestScopePropagator,
+                patchSetId,
+                new SubmissionId(change),
+                mergedInto,
+                mergeResultRevId);
       }
     }
 
@@ -482,8 +493,18 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(Context ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
+    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
+      // TODO(dborowitz): Merge email templates so we only have to send one.
+      Runnable e = new ReplaceEmailTask(ctx);
+      if (requestScopePropagator != null) {
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
+      } else {
+        e.run();
+      }
+    }
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
@@ -496,11 +517,15 @@
     }
   }
 
-  @Override
-  public void asyncPostUpdate(Context ctx) {
-    reviewerAdditions.asyncPostUpdate(ctx);
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      // TODO(dborowitz): Merge email templates so we only have to send one.
+  private class ReplaceEmailTask implements Runnable {
+    private final Context ctx;
+
+    private ReplaceEmailTask(Context ctx) {
+      this.ctx = ctx;
+    }
+
+    @Override
+    public void run() {
       try {
         ReplacePatchSetSender emailSender =
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
@@ -528,8 +553,10 @@
             "Cannot send email for new patch set %s", newPatchSet.id());
       }
     }
-    if (mergedByPushOp != null) {
-      mergedByPushOp.asyncPostUpdate(ctx);
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
     }
   }
 
@@ -586,6 +613,11 @@
     return cmd;
   }
 
+  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RevWalk rw = ctx.getRevWalk();
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index b8c6bf0..df38118 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -59,12 +59,9 @@
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -107,7 +104,6 @@
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
   private final OneOffRequestContext oneOffRequestContext;
-  private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
   private final Provider<InternalChangeQuery> queryProvider;
   private final DynamicMap<MailFilter> mailFilters;
@@ -127,7 +123,6 @@
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
       OneOffRequestContext oneOffRequestContext,
-      PatchListCache patchListCache,
       PatchSetUtil psUtil,
       Provider<InternalChangeQuery> queryProvider,
       DynamicMap<MailFilter> mailFilters,
@@ -144,7 +139,6 @@
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
     this.oneOffRequestContext = oneOffRequestContext;
-    this.patchListCache = patchListCache;
     this.psUtil = psUtil;
     this.queryProvider = queryProvider;
     this.mailFilters = mailFilters;
@@ -315,7 +309,7 @@
     }
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
     private final List<MailComment> parsedComments;
     private final String tag;
@@ -331,8 +325,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws UnprocessableEntityException, PatchListNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws UnprocessableEntityException {
       patchSet = psUtil.get(ctx.getNotes(), psId);
       notes = ctx.getNotes();
       if (patchSet == null) {
@@ -360,26 +353,6 @@
 
     @Override
     public void postUpdate(Context ctx) throws Exception {
-      // Get previous approvals from this user
-      Map<String, Short> approvals = new HashMap<>();
-      approvalsUtil
-          .byPatchSetUser(
-              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
-          .forEach(a -> approvals.put(a.label(), a.value()));
-      // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
-      // are always the same here.
-      commentAdded.fire(
-          notes.getChange(),
-          patchSet,
-          ctx.getAccount(),
-          changeMessage.getMessage(),
-          approvals,
-          approvals,
-          ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) throws Exception {
       String patchSetComment = null;
       if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
         patchSetComment = parsedComments.get(0).getMessage();
@@ -396,7 +369,23 @@
               patchSetComment,
               ImmutableList.of(),
               ctx.getRepoView())
-          .send();
+          .sendAsync();
+      // Get previous approvals from this user
+      Map<String, Short> approvals = new HashMap<>();
+      approvalsUtil
+          .byPatchSetUser(
+              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
+          .forEach(a -> approvals.put(a.label(), a.value()));
+      // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
+      // are always the same here.
+      commentAdded.fire(
+          notes.getChange(),
+          patchSet,
+          ctx.getAccount(),
+          changeMessage.getMessage(),
+          approvals,
+          approvals,
+          ctx.getWhen());
     }
 
     private ChangeMessage generateChangeMessage(ChangeContext ctx) {
@@ -424,8 +413,7 @@
     }
 
     private HumanComment persistentCommentFromMailComment(
-        ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
-        throws PatchListNotAvailableException {
+        ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment) {
       String fileName;
       // The patch set that this comment is based on is different if this
       // comment was sent in reply to a comment on a previous patch set.
@@ -457,7 +445,7 @@
         comment.range = mailComment.getInReplyTo().range;
         comment.unresolved = mailComment.getInReplyTo().unresolved;
       }
-      CommentsUtil.setCommentCommitId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), patchSetForComment);
       return comment;
     }
   }
diff --git a/java/com/google/gerrit/server/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/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index f29c6e6..70d1eaf 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -40,8 +39,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -80,7 +77,6 @@
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComments(
@@ -92,8 +88,7 @@
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      PatchSetUtil psUtil) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
     this.queryBuilderProvider = queryBuilderProvider;
@@ -103,7 +98,6 @@
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -176,14 +170,13 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws PatchListNotAvailableException, PermissionBackendException {
+    public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
       ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
       boolean dirty = false;
       for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
         PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
-        setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+        commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
         commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
         comments.add(humanCommentFormatter.format(c));
       }
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 20c4b48..67049e8 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -34,6 +35,7 @@
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -48,10 +50,15 @@
 
   private boolean fillAccounts = true;
   private boolean fillPatchSet;
+  private CommentContextLoader.Factory commentContextLoaderFactory;
+  private CommentContextLoader commentContextLoader;
 
   @Inject
-  CommentJson(AccountLoader.Factory accountLoaderFactory) {
+  CommentJson(
+      AccountLoader.Factory accountLoaderFactory,
+      CommentContextLoader.Factory commentContextLoaderFactory) {
     this.accountLoaderFactory = accountLoaderFactory;
+    this.commentContextLoaderFactory = commentContextLoaderFactory;
   }
 
   CommentJson setFillAccounts(boolean fillAccounts) {
@@ -64,6 +71,13 @@
     return this;
   }
 
+  CommentJson setEnableContext(boolean enableContext, Project.NameKey project) {
+    if (enableContext) {
+      this.commentContextLoader = commentContextLoaderFactory.create(project);
+    }
+    return this;
+  }
+
   public HumanCommentFormatter newHumanCommentFormatter() {
     return new HumanCommentFormatter();
   }
@@ -79,6 +93,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return info;
     }
 
@@ -103,6 +120,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return out;
     }
 
@@ -118,6 +138,9 @@
       if (loader != null) {
         loader.fill();
       }
+      if (commentContextLoader != null) {
+        commentContextLoader.fill();
+      }
       return out;
     }
 
@@ -148,6 +171,9 @@
         r.author = loader.get(c.author.getId());
       }
       r.commitId = c.getCommitId().getName();
+      if (commentContextLoader != null) {
+        r.contextLines = commentContextLoader.getContext(r);
+      }
     }
 
     protected Range toRange(Comment.Range commentRange) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 3effa8c..e2abf29 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -29,6 +29,8 @@
 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;
@@ -43,6 +45,7 @@
 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;
@@ -64,10 +67,12 @@
   private final GitPositionTransformer positionTransformer =
       new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
   private final PatchListCache patchListCache;
+  private final CommentsUtil commentsUtil;
 
   @Inject
-  public CommentPorter(PatchListCache patchListCache) {
+  public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil) {
     this.patchListCache = patchListCache;
+    this.commentsUtil = commentsUtil;
   }
 
   /**
@@ -102,8 +107,18 @@
 
   private ImmutableList<HumanComment> filterToRelevant(
       List<HumanComment> allComments, PatchSet targetPatchset) {
-    return allComments.stream()
-        .filter(comment -> comment.key.patchSetId < targetPatchset.number())
+    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());
   }
 
@@ -118,13 +133,21 @@
       ImmutableList<HumanComment> patchsetComments = commentsPerPatchset.get(originalPatchsetId);
       PatchSet originalPatchset =
           notes.getPatchSets().get(PatchSet.id(notes.getChangeId(), originalPatchsetId));
-      portedComments.addAll(
-          portSamePatchset(
-              notes.getProjectName(),
-              notes.getChange(),
-              originalPatchset,
-              targetPatchset,
-              patchsetComments));
+      if (originalPatchset != null) {
+        portedComments.addAll(
+            portSamePatchset(
+                notes.getProjectName(),
+                notes.getChange(),
+                originalPatchset,
+                targetPatchset,
+                patchsetComments));
+      } else {
+        logger.atWarning().log(
+            String.format(
+                "Some comments which should be ported refer to the non-existent patchset %s of"
+                    + " change %d. Omitting %d affected comments.",
+                originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()));
+      }
     }
     return portedComments.build();
   }
@@ -187,13 +210,22 @@
       PatchSet targetPatchset,
       short side)
       throws PatchListNotAvailableException {
-    ObjectId originalCommit =
-        CommentsUtil.determineCommitId(patchListCache, change, originalPatchset, side);
-    ObjectId targetCommit =
-        CommentsUtil.determineCommitId(patchListCache, change, targetPatchset, side);
+    ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
+    ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
     return loadCommitMappings(project, originalCommit, targetCommit);
   }
 
+  private ObjectId determineCommitId(Change change, PatchSet patchset, short side) {
+    return commentsUtil
+        .determineCommitId(change, patchset, side)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Commit indicated by change %d, patchset %d, side %d doesn't exist.",
+                        change.getId().get(), patchset.id().get(), side)));
+  }
+
   private ImmutableSet<Mapping> loadCommitMappings(
       Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
       throws PatchListNotAvailableException {
@@ -264,6 +296,11 @@
       // No line -> use 0 = file comment or any other comment type without an explicit line.
       portedComment.lineNbr = newPosition.lineRange().map(range -> range.start() + 1).orElse(0);
     }
+    if (Patch.PATCHSET_LEVEL.equals(portedComment.key.filename)) {
+      // Correct the side of the comment to Side.REVISION (= 1) if the comment was changed to
+      // patchset level.
+      portedComment.side = 1;
+    }
     return portedComment;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index b749e6a..8476767 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.HumanComment;
@@ -32,8 +31,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -51,20 +48,17 @@
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   CreateDraftComment(
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      PatchSetUtil psUtil) {
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -81,6 +75,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 =
@@ -106,8 +105,7 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, UnprocessableEntityException,
-            PatchListNotAvailableException {
+        throws ResourceNotFoundException, UnprocessableEntityException {
       PatchSet ps = psUtil.get(ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
@@ -128,7 +126,7 @@
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
-      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
 
       commentsUtil.putHumanComments(
           ctx.getUpdate(psId), HumanComment.Status.DRAFT, Collections.singleton(comment));
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 71fd4d2..51a0b8e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
-
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
@@ -28,8 +26,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -45,18 +41,13 @@
   private final BatchUpdate.Factory updateFactory;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
 
   @Inject
   DeleteDraftComment(
-      BatchUpdate.Factory updateFactory,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache) {
+      BatchUpdate.Factory updateFactory, CommentsUtil commentsUtil, PatchSetUtil psUtil) {
     this.updateFactory = updateFactory;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -79,8 +70,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, PatchListNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
       Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
@@ -92,7 +82,7 @@
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
       HumanComment c = maybeComment.get();
-      setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(c, ctx.getChange(), ps);
       commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 22c3567..4b813df 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -147,7 +146,7 @@
     return Response.none();
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final ProjectState projectState;
     private final AccountState accountState;
     private final String label;
@@ -222,30 +221,17 @@
 
     @Override
     public void postUpdate(Context ctx) {
-      voteDeleted.fire(
-          change,
-          ps,
-          accountState,
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          changeMessage.getMessage(),
-          ctx.getIdentifiedUser().state(),
-          ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       if (changeMessage == null) {
         return;
       }
 
+      IdentifiedUser user = ctx.getIdentifiedUser();
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
         if (notify.shouldNotify()) {
           ReplyToChangeSender emailSender =
               deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          emailSender.setFrom(ctx.getIdentifiedUser().getAccountId());
+          emailSender.setFrom(user.getAccountId());
           emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
           emailSender.setNotify(notify);
           emailSender.setMessageId(
@@ -255,6 +241,17 @@
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
+
+      voteDeleted.fire(
+          change,
+          ps,
+          accountState,
+          newApprovals,
+          oldApprovals,
+          input.notify,
+          changeMessage.getMessage(),
+          user.state(),
+          ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index b842f55..fec7bdc 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -19,38 +19,56 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class ListChangeComments implements RestReadView<ChangeResource> {
   private final ChangeMessagesUtil changeMessagesUtil;
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
+  private final CommentContextLoader.Factory commentContextFactory;
+
+  private boolean includeContext;
+
+  /**
+   * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
+   * response will contain the lines of the source file where the comment was written.
+   *
+   * @param context If true, comment context will be attached to the response
+   */
+  @Option(name = "--enable-context")
+  public void setContext(boolean context) {
+    this.includeContext = context;
+  }
 
   @Inject
   ListChangeComments(
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
-      ChangeMessagesUtil changeMessagesUtil) {
+      ChangeMessagesUtil changeMessagesUtil,
+      CommentContextLoader.Factory commentContextFactory) {
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.changeMessagesUtil = changeMessagesUtil;
+    this.commentContextFactory = commentContextFactory;
   }
 
   @Override
@@ -70,7 +88,8 @@
 
   private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
-    ImmutableList<CommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
+    ImmutableList<CommentInfo> commentInfos =
+        getCommentFormatter(rsrc.getProject()).formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
     CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
@@ -78,7 +97,8 @@
 
   private Map<String, List<CommentInfo>> getAsMap(
       Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
-    Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
+    Map<String, List<CommentInfo>> commentInfosMap =
+        getCommentFormatter(rsrc.getProject()).format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -86,7 +106,12 @@
     return commentInfosMap;
   }
 
-  private CommentJson.HumanCommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newHumanCommentFormatter();
+  private CommentJson.HumanCommentFormatter getCommentFormatter(Project.NameKey project) {
+    return commentJson
+        .get()
+        .setFillAccounts(true)
+        .setFillPatchSet(true)
+        .setEnableContext(includeContext, project)
+        .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 62d89ee..69e2788 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -29,6 +29,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.CommentContextLoader;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
@@ -205,6 +206,7 @@
     factory(AccountLoader.Factory.class);
     factory(ChangeInserter.Factory.class);
     factory(ChangeResource.Factory.class);
+    factory(CommentContextLoader.Factory.class);
     factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 9064dc8..3367ca6 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -120,7 +119,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -449,7 +447,7 @@
           ccByEmail.addAll(addition.reviewersByEmail);
         }
       }
-      addReviewersEmail.emailReviewers(
+      addReviewersEmail.emailReviewersAsync(
           user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
     }
   }
@@ -616,6 +614,7 @@
         ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
         ensureRangeIsValid(path, comment.range);
         ensureValidPatchsetLevelComment(path, comment);
+        ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
       }
     }
   }
@@ -662,6 +661,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 {
@@ -860,7 +869,7 @@
     abstract Comment.Range range();
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final ProjectState projectState;
     private final PatchSet.Id psId;
     private final ReviewInput in;
@@ -883,7 +892,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceConflictException, UnprocessableEntityException, IOException,
-            PatchListNotAvailableException, CommentsRejectedException {
+            CommentsRejectedException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getNotes(), psId);
@@ -906,7 +915,7 @@
     }
 
     @Override
-    public void asyncPostUpdate(Context ctx) {
+    public void postUpdate(Context ctx) {
       if (message == null) {
         return;
       }
@@ -924,20 +933,12 @@
                   in.message,
                   labelDelta,
                   ctx.getRepoView())
-              .send();
+              .sendAsync();
         } catch (IOException ex) {
           throw new StorageException(
               String.format("Repository %s not found", ctx.getProject().get()), ex);
         }
       }
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (message == null) {
-        return;
-      }
-
       commentAdded.fire(
           notes.getChange(),
           ps,
@@ -949,7 +950,7 @@
     }
 
     private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws PatchListNotAvailableException, CommentsRejectedException {
+        throws CommentsRejectedException {
       Map<String, List<CommentInput>> inputComments = in.comments;
       if (inputComments == null) {
         inputComments = Collections.emptyMap();
@@ -1001,7 +1002,7 @@
             comment.message = inputComment.message;
           }
 
-          setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
           comment.setLineNbrAndRange(inputComment.line, inputComment.range);
           comment.tag = in.tag;
 
@@ -1080,8 +1081,7 @@
       return !newRobotComments.isEmpty();
     }
 
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx)
-        throws PatchListNotAvailableException {
+    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
       List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
 
       Set<CommentSetEntry> existingIds =
@@ -1101,8 +1101,7 @@
     }
 
     private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput)
-        throws PatchListNotAvailableException {
+        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
       RobotComment robotComment =
           commentsUtil.newRobotComment(
               ctx,
@@ -1117,7 +1116,7 @@
       robotComment.properties = robotCommentInput.properties;
       robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
       robotComment.tag = in.tag;
-      setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
       robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
       return robotComment;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index f327f16..84a3d89 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
@@ -32,8 +31,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -54,7 +51,6 @@
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final Provider<CommentJson> commentJson;
-  private final PatchListCache patchListCache;
 
   @Inject
   PutDraftComment(
@@ -62,14 +58,12 @@
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      Provider<CommentJson> commentJson,
-      PatchListCache patchListCache) {
+      Provider<CommentJson> commentJson) {
     this.updateFactory = updateFactory;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.commentJson = commentJson;
-    this.patchListCache = patchListCache;
   }
 
   @Override
@@ -86,8 +80,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);
@@ -110,8 +108,7 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, PatchListNotAvailableException {
+    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
       Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
@@ -139,7 +136,7 @@
         commentsUtil.deleteHumanComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
-      setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+      commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
       commentsUtil.putHumanComments(
           update,
           HumanComment.Status.DRAFT,
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/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 26b9d4c..7faf8e0 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -44,7 +44,6 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -109,7 +108,7 @@
     return Response.ok(json.noOptions().format(op.change));
   }
 
-  private class Op implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class Op implements BatchUpdateOp {
     private final RestoreInput input;
 
     private Change change;
@@ -150,12 +149,6 @@
 
     @Override
     public void postUpdate(Context ctx) {
-      changeRestored.fire(
-          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       try {
         ReplyToChangeSender emailSender =
             restoredSenderFactory.create(ctx.getProject(), change.getId());
@@ -167,6 +160,8 @@
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
+      changeRestored.fire(
+          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index d128186..ca39a57 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -72,7 +72,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CherryPickChange.Result;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -601,7 +600,7 @@
     }
   }
 
-  private class NotifyOp implements BatchUpdateOp, AsyncPostUpdateOp {
+  private class NotifyOp implements BatchUpdateOp {
     private final Change change;
     private final Change.Id revertChangeId;
 
@@ -611,13 +610,9 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(
           change, changeNotesFactory.createChecked(revertChangeId).getChange(), ctx.getWhen());
-    }
-
-    @Override
-    public void asyncPostUpdate(Context ctx) {
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index c7671b0..4efa4c8 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -19,14 +19,22 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
-class EmailMerge {
+class EmailMerge implements Runnable, RequestContext {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   interface Factory {
@@ -38,7 +46,10 @@
         RepoView repoView);
   }
 
+  private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
+  private final ThreadLocalRequestContext requestContext;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final MessageIdGenerator messageIdGenerator;
 
   private final Project.NameKey project;
@@ -49,14 +60,20 @@
 
   @Inject
   EmailMerge(
+      @SendEmailExecutor ExecutorService executor,
       MergedSender.Factory mergedSenderFactory,
+      ThreadLocalRequestContext requestContext,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
       MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
       @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
       @Assisted NotifyResolver.Result notify,
       @Assisted RepoView repoView) {
+    this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
+    this.requestContext = requestContext;
+    this.identifiedUserFactory = identifiedUserFactory;
     this.messageIdGenerator = messageIdGenerator;
     this.project = project;
     this.change = change;
@@ -65,7 +82,14 @@
     this.repoView = repoView;
   }
 
-  public void send() {
+  void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
+    RequestContext old = requestContext.setContext(this);
     try {
       MergedSender emailSender = mergedSenderFactory.create(project, change.getId());
       if (submitter != null) {
@@ -77,6 +101,21 @@
       emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
+    } finally {
+      requestContext.setContext(old);
     }
   }
+
+  @Override
+  public String toString() {
+    return "send-email merged";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    if (submitter != null) {
+      return identifiedUserFactory.create(submitter).getRealUser();
+    }
+    throw new OutOfScopeException("No user on email thread");
+  }
 }
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 3629640..edc3725 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -275,13 +275,6 @@
         rebaseOp.postUpdate(ctx);
       }
     }
-
-    @Override
-    public void asyncPostUpdateImpl(Context ctx) {
-      if (rebaseOp != null) {
-        rebaseOp.asyncPostUpdate(ctx);
-      }
-    }
   }
 
   private class MergeIfNecessaryOp extends SubmitStrategyOp {
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 201eba8..3cc566b 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -43,7 +43,6 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.update.AsyncPostUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -61,7 +60,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-abstract class SubmitStrategyOp implements BatchUpdateOp, AsyncPostUpdateOp {
+abstract class SubmitStrategyOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected final SubmitStrategy.Arguments args;
@@ -484,22 +483,8 @@
       }
     }
 
-    if (mergeResultRev != null && !args.dryrun) {
-      args.changeMerged.fire(
-          updatedChange,
-          mergedPatchSet,
-          args.accountCache.get(submitter.accountId()).orElse(null),
-          args.mergeTip.getCurrentTip().name(),
-          ctx.getWhen());
-    }
-  }
-
-  /**
-   * Assume the change must have been merged at this point, otherwise we would have failed in one of
-   * the other steps in postUpdate (which is done prior to this method).
-   */
-  @Override
-  public final void asyncPostUpdate(Context ctx) {
+    // Assume the change must have been merged at this point, otherwise we would
+    // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
           .create(
@@ -508,11 +493,18 @@
               submitter.accountId(),
               ctx.getNotify(getId()),
               ctx.getRepoView())
-          .send();
+          .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
     }
-    asyncPostUpdateImpl(ctx);
+    if (mergeResultRev != null && !args.dryrun) {
+      args.changeMerged.fire(
+          updatedChange,
+          mergedPatchSet,
+          args.accountCache.get(submitter.accountId()).orElse(null),
+          args.mergeTip.getCurrentTip().name(),
+          ctx.getWhen());
+    }
   }
 
   /**
@@ -537,12 +529,6 @@
   protected void postUpdateImpl(Context ctx) throws Exception {}
 
   /**
-   * @see #asyncPostUpdate(Context)
-   * @param ctx
-   */
-  protected void asyncPostUpdateImpl(Context ctx) {}
-
-  /**
    * Amend the commit with gitlink update
    *
    * @param commit
diff --git a/java/com/google/gerrit/server/update/AsyncPostUpdateOp.java b/java/com/google/gerrit/server/update/AsyncPostUpdateOp.java
deleted file mode 100644
index 9a989b7..0000000
--- a/java/com/google/gerrit/server/update/AsyncPostUpdateOp.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import com.google.gerrit.server.config.SysExecutorModule;
-
-/** Base interface for operations performed asynchronously as part of a {@link BatchUpdate}. */
-public interface AsyncPostUpdateOp {
-
-  /**
-   * Override this method to do something after the update e.g. send emails. This method will be
-   * invoked asynchronously, and when invoked, the invoking method will not wait for the async
-   * updates to finish. This method will be called after {@link BatchUpdateOp} operations and {@link
-   * RepoOnlyOp} are finished.
-   *
-   * <p>The maximum amount of threads in the thread pool is decided by
-   * asyncPostUpdate.threadPoolSize. When asyncPostUpdate.threadPoolSize is not specified, the
-   * deprecated sendemail.threadPoolSize is used (see {@link
-   * SysExecutorModule#provideSendEmailExecutor}). This is the case for legacy reasons, since in the
-   * past only some emails were sent async (and sendemail.threadPoolSize) was used, and now all
-   * emails (and possibly others) are done async, so asyncPostUpdate.threadPoolSize is used.
-   *
-   * @param ctx context
-   */
-  default void asyncPostUpdate(Context ctx) throws Exception {}
-}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 2fdc124..166e88d 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.config.AsyncPostUpdateExecutor;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
@@ -63,7 +62,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
@@ -77,7 +75,6 @@
 import java.util.Optional;
 import java.util.TimeZone;
 import java.util.TreeMap;
-import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -335,8 +332,6 @@
   private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final ChangeIndexer indexer;
   private final GitReferenceUpdated gitRefUpdated;
-  private final ThreadLocalRequestContext requestContext;
-  private final ExecutorService executorService;
 
   private final Project.NameKey project;
   private final CurrentUser user;
@@ -365,8 +360,6 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeIndexer indexer,
       GitReferenceUpdated gitRefUpdated,
-      ThreadLocalRequestContext requestContext,
-      @AsyncPostUpdateExecutor ExecutorService executorService,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Timestamp when) {
@@ -376,8 +369,6 @@
     this.updateManagerFactory = updateManagerFactory;
     this.indexer = indexer;
     this.gitRefUpdated = gitRefUpdated;
-    this.requestContext = requestContext;
-    this.executorService = executorService;
     this.project = project;
     this.user = user;
     this.when = when;
@@ -646,40 +637,23 @@
     return new ChangeContextImpl(notes);
   }
 
-  private void executePostOps() {
+  private void executePostOps() throws Exception {
     ContextImpl ctx = new ContextImpl();
     for (BatchUpdateOp op : ops.values()) {
-      postUpdate(ctx, op);
-      if (op instanceof AsyncPostUpdateOp) {
-        asyncPostUpdate(ctx, ((AsyncPostUpdateOp) op));
+      try (TraceContext.TraceTimer ignored =
+          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        op.postUpdate(ctx);
       }
     }
 
     for (RepoOnlyOp op : repoOnlyOps) {
-      postUpdate(ctx, op);
-      if (op instanceof AsyncPostUpdateOp) {
-        asyncPostUpdate(ctx, ((AsyncPostUpdateOp) op));
+      try (TraceContext.TraceTimer ignored =
+          TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+        op.postUpdate(ctx);
       }
     }
   }
 
-  /** Invoke the postUpdate methods synchronously. */
-  private void postUpdate(ContextImpl ctx, RepoOnlyOp op) {
-    try (TraceContext.TraceTimer ignored =
-        TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
-      op.postUpdate(ctx);
-    } catch (Exception ex) {
-      logDebug(
-          String.format(
-              "postUpdate for project %s failed for user %s", ctx.getProject(), ctx.getUser()));
-    }
-  }
-
-  /** Invoke the asyncPostUpdate methods asynchronously. */
-  private void asyncPostUpdate(ContextImpl ctx, AsyncPostUpdateOp op) {
-    executorService.execute(new ExecuteAsyncPostUpdate(op, ctx, user, requestContext));
-  }
-
   private static void logDebug(String msg) {
     // Only log if there is a requestId assigned, since those are the
     // expensive/complicated requests like MergeOp. Doing it every time would be
diff --git a/java/com/google/gerrit/server/update/ExecuteAsyncPostUpdate.java b/java/com/google/gerrit/server/update/ExecuteAsyncPostUpdate.java
deleted file mode 100644
index e640ab1..0000000
--- a/java/com/google/gerrit/server/update/ExecuteAsyncPostUpdate.java
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-
-/** Executes {@link AsyncPostUpdateOp#asyncPostUpdate(Context)} on a specific op, asynchronously. */
-public class ExecuteAsyncPostUpdate implements Runnable, RequestContext {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final AsyncPostUpdateOp op;
-  private final Context ctx;
-  private final CurrentUser user;
-  private final ThreadLocalRequestContext threadLocalRequestContext;
-
-  ExecuteAsyncPostUpdate(
-      AsyncPostUpdateOp op,
-      Context ctx,
-      CurrentUser user,
-      ThreadLocalRequestContext threadLocalRequestContext) {
-    this.op = op;
-    this.ctx = ctx;
-    this.user = user;
-    this.threadLocalRequestContext = threadLocalRequestContext;
-  }
-
-  @Override
-  public void run() {
-    RequestContext old = threadLocalRequestContext.setContext(this);
-    try {
-      op.asyncPostUpdate(ctx);
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot perform async post update for repo %s and user %s",
-          ctx.getProject(), ctx.getAccount().account().getName());
-    } finally {
-      threadLocalRequestContext.setContext(old);
-    }
-  }
-
-  @Override
-  public String toString() {
-    return "async-post-update";
-  }
-
-  @Override
-  public CurrentUser getUser() {
-    return user;
-  }
-}
diff --git a/java/com/google/gerrit/server/update/RepoOnlyOp.java b/java/com/google/gerrit/server/update/RepoOnlyOp.java
index f9b41c4..7e9c47e 100644
--- a/java/com/google/gerrit/server/update/RepoOnlyOp.java
+++ b/java/com/google/gerrit/server/update/RepoOnlyOp.java
@@ -30,11 +30,10 @@
   default void updateRepo(RepoContext ctx) throws Exception {}
 
   /**
-   * Override this method to do something after the update e.g. run hooks. This method will
-   * <strong>NOT</strong> be invoked asynchronously. This method will be finished before {@link
-   * AsyncPostUpdateOp#asyncPostUpdate} is called.
+   * Override this method to do something after the update e.g. send email or run hooks
    *
    * @param ctx context
    */
+  // TODO(dborowitz): Support async operations?
   default void postUpdate(Context ctx) throws Exception {}
 }
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 2b09c49..56b1dda 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -17,7 +17,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.AttentionSetSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
@@ -25,8 +27,10 @@
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 
-public class AttentionSetEmail {
+public class AttentionSetEmail implements Runnable, RequestContext {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -51,6 +55,7 @@
         Account.Id attentionUserId);
   }
 
+  private ExecutorService sendEmailsExecutor;
   private AttentionSetSender sender;
   private Context ctx;
   private Change change;
@@ -61,12 +66,14 @@
 
   @Inject
   AttentionSetEmail(
+      @SendEmailExecutor ExecutorService executor,
       @Assisted AttentionSetSender sender,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
       @Assisted MessageIdGenerator.MessageId messageId,
       @Assisted Account.Id attentionUserId) {
+    this.sendEmailsExecutor = executor;
     this.sender = sender;
     this.ctx = ctx;
     this.change = change;
@@ -75,7 +82,13 @@
     this.attentionUserId = attentionUserId;
   }
 
-  public void send() {
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
     try {
       AccountState accountState =
           ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
@@ -91,4 +104,14 @@
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
   }
+
+  @Override
+  public String toString() {
+    return "send-email comments";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    return ctx.getUser();
+  }
 }
diff --git a/java/com/google/gerrit/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/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index c7fe74e..cfd17f4 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -90,9 +90,12 @@
         command("git-receive-pack").to(Commands.key(git, "receive-pack"));
         command("gerrit-receive-pack").to(Commands.key(git, "receive-pack"));
         command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack"));
-        command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
       }
     }
+
+    if (!slaveMode) {
+      command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
+    }
     command("suexec").to(SuExec.class);
     listener().to(ShowCaches.StartupListener.class);
 
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index f73020a..fec9b27 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -128,7 +128,6 @@
   }
 
   public synchronized @Nullable Message peekMessage() {
-    waitForEmails();
     if (messagesRead >= messages.size()) {
       return null;
     }
@@ -136,14 +135,9 @@
   }
 
   public synchronized @Nullable Message nextMessage() {
-    waitForEmails();
     Message msg = peekMessage();
-    readOneMessage();
-    return msg;
-  }
-
-  public synchronized void readOneMessage() {
     messagesRead++;
+    return msg;
   }
 
   public ImmutableList<Message> getMessages() {
@@ -166,7 +160,7 @@
     // a single thread in tests (tricky because most callers just use the
     // default executor).
     for (WorkQueue.Task<?> task : workQueue.getTasks()) {
-      if (task.toString().contains("async-post-update")) {
+      if (task.toString().contains("send-email")) {
         try {
           task.get();
         } catch (ExecutionException | InterruptedException e) {
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 693fd90..6c9fbed 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
-import com.google.gerrit.server.config.AsyncPostUpdateExecutor;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultUrlFormatter;
@@ -59,6 +58,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
@@ -279,7 +279,7 @@
 
   @Provides
   @Singleton
-  @AsyncPostUpdateExecutor
+  @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
     return newDirectExecutorService();
   }
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/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 0bfd525..d4affb7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -284,11 +284,6 @@
     String fileName = "a_new_file.txt";
     String fileContent = "First line\nSecond line\n";
     PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
-
-    // Emails are sent here async which triggers cache hits, so we must wait until those email are
-    // actually sent.
-    sender.clear();
-
     String triplet = project.get() + "~master~" + result.getChangeId();
     CacheStats startPatch = cloneStats(fileCache.stats());
     CacheStats startIntra = cloneStats(intraCache.stats());
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 702602b..d76d114 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -27,16 +27,21 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation;
 import com.google.gerrit.acceptance.testsuite.change.TestPatchset;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -57,9 +62,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 +80,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 +97,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 +114,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 +126,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 +141,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 +183,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 +208,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 +239,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 +256,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 +272,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 +289,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 +304,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 +313,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));
 
@@ -344,8 +334,7 @@
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comments.
-    String commentUuid =
-        changeOps.change(changeId).patchset(patchset1Id).newComment().onParentCommit().create();
+    String commentUuid = newComment(patchset1Id).onParentCommit().create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -353,13 +342,58 @@
   }
 
   @Test
+  public void commentOnInvalidParentIsPorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String commentUuid = newComment(patchset1Id).onSecondParentCommit().create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+  }
+
+  @Test
+  public void commentsOnInvalidPositionArePorted() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    String commentUuid1 = newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+    String commentUuid2 = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+    assertThat(portedComments)
+        .comparingElementsUsing(hasUuid())
+        .containsExactly(commentUuid1, commentUuid2);
+  }
+
+  @Test
+  public void commentsOnInvalidPositionKeepTheirInvalidPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+    // Add comments.
+    newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+    assertThatMap(portedComments).keys().containsExactly("not-existing file");
+  }
+
+  @Test
   public void portedCommentHasOriginalUuid() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId = changeOps.newChange().create();
     PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
     PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
     // Add comment.
-    String commentUuid = changeOps.change(changeId).patchset(patchset1Id).newComment().create();
+    String commentUuid = newComment(patchset1Id).create();
 
     List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
 
@@ -373,7 +407,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);
 
@@ -388,13 +422,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);
@@ -413,8 +441,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);
 
@@ -428,13 +455,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);
 
@@ -448,15 +469,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);
 
@@ -471,8 +486,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);
 
@@ -487,13 +501,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);
@@ -510,13 +518,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);
 
@@ -530,7 +532,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);
 
@@ -544,7 +546,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);
 
@@ -561,13 +563,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);
 
@@ -591,10 +587,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -625,10 +618,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -654,10 +644,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)
@@ -692,10 +679,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -735,10 +719,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -778,10 +759,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -810,10 +788,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(2)
             .charOffset(2)
             .toLine(3)
@@ -844,10 +819,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(1)
             .charOffset(2)
             .toLine(2)
@@ -878,10 +850,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(1)
             .charOffset(2)
             .toLine(3)
@@ -912,10 +881,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(1)
             .charOffset(2)
             .toLine(3)
@@ -944,10 +910,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(3)
@@ -977,10 +940,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(2)
             .charOffset(2)
             .toLine(4)
@@ -1009,10 +969,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(3)
             .charOffset(2)
             .toLine(4)
@@ -1049,10 +1006,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(changeId)
-            .patchset(patchset1Id)
-            .newComment()
+        newComment(patchset1Id)
             .fromLine(2)
             .charOffset(2)
             .toLine(4)
@@ -1076,10 +1030,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)
@@ -1095,6 +1046,42 @@
   }
 
   @Test
+  public void overlappingRangeCommentsArePortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 2\n")
+            .create();
+    // Add comment.
+    String commentUuid1 =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(0)
+            .toLine(2)
+            .charOffset(3)
+            .ofFile("myFile")
+            .create();
+    String commentUuid2 =
+        newComment(patchset1Id)
+            .fromLine(2)
+            .charOffset(1)
+            .toLine(2)
+            .charOffset(4)
+            .ofFile("myFile")
+            .create();
+
+    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+    assertThat(portedComment1).range().startLine().isEqualTo(3);
+    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+    assertThat(portedComment2).range().startLine().isEqualTo(3);
+  }
+
+  @Test
   public void portedLineCommentCanHandleAddedLines() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId =
@@ -1108,14 +1095,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);
 
@@ -1136,14 +1116,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);
 
@@ -1159,14 +1132,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);
 
@@ -1190,14 +1156,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);
 
@@ -1227,14 +1186,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);
 
@@ -1261,14 +1213,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);
 
@@ -1291,14 +1236,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);
 
@@ -1319,14 +1257,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);
 
@@ -1347,14 +1278,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);
 
@@ -1375,14 +1299,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);
 
@@ -1398,14 +1315,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);
@@ -1415,6 +1325,28 @@
   }
 
   @Test
+  public void overlappingLineCommentsArePortedToNewPosition() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps
+            .change(changeId)
+            .newPatchset()
+            .file("myFile")
+            .content("Line 1\nLine 1.1\nLine 2\n")
+            .create();
+    // Add comment.
+    String commentUuid1 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+    String commentUuid2 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+    CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+    assertThat(portedComment1).line().isEqualTo(3);
+    CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+    assertThat(portedComment2).line().isEqualTo(3);
+  }
+
+  @Test
   public void portedFileCommentIsObliviousToAdjustedFileContent() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId =
@@ -1428,13 +1360,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);
 
@@ -1450,13 +1376,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);
 
@@ -1481,7 +1401,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);
 
@@ -1499,13 +1419,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);
@@ -1528,7 +1442,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);
 
@@ -1544,7 +1458,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);
 
@@ -1565,10 +1479,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)
@@ -1605,14 +1516,7 @@
         changeOps.change(childChangeId).newPatchset().parent().patchset(parentPatchset2Id).create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(childChangeId)
-            .patchset(childPatchset1Id)
-            .newComment()
-            .onParentCommit()
-            .onLine(1)
-            .ofFile("myFile")
-            .create();
+        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("myFile").create();
 
     CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
 
@@ -1654,14 +1558,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(childChangeId)
-            .patchset(childPatchset1Id)
-            .newComment()
-            .onParentCommit()
-            .onLine(1)
-            .ofFile("file1")
-            .create();
+        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
 
     CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
 
@@ -1703,14 +1600,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(childChangeId)
-            .patchset(childPatchset1Id)
-            .newComment()
-            .onSecondParentCommit()
-            .onLine(1)
-            .ofFile("file2")
-            .create();
+        newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
 
     CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
 
@@ -1758,14 +1648,7 @@
             .create();
     // Add comment.
     String commentUuid =
-        changeOps
-            .change(childChangeId)
-            .patchset(childPatchset1Id)
-            .newComment()
-            .onAutoMergeCommit()
-            .onLine(1)
-            .ofFile("file1")
-            .create();
+        newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
 
     CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
 
@@ -1776,6 +1659,185 @@
     assertThat(portedComment).line().isGreaterThan(2);
   }
 
+  @Test
+  public void commentOnFirstParentIsPortedToSingleParentWhenPatchsetChangedToNonMergeCommit()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id parent1PatchsetId2 =
+        changeOps
+            .change(parent1ChangeId)
+            .newPatchset()
+            .file("file1")
+            .content("Line 0\nLine 1\n")
+            .create();
+    PatchSet.Id childPatchset2Id =
+        changeOps
+            .change(childChangeId)
+            .newPatchset()
+            .parent()
+            .patchset(parent1PatchsetId2)
+            .create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
+
+    CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+    assertThat(portedComment).side().isEqualTo(Side.PARENT);
+    assertThat(portedComment).parent().isEqualTo(1);
+  }
+
+  @Test
+  public void commentOnSecondParentBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+      throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id childPatchset2Id =
+        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+    assertThat(portedComment).side().isNull();
+    assertThat(portedComment).parent().isNull();
+  }
+
+  @Test
+  // TODO(ghareeb): Adjust implementation in CommentsUtil to use the new auto-merge code instead of
+  // PatchListCache#getOldId which returns the wrong result if a change isn't a merge commit.
+  @Ignore
+  public void
+      commentOnAutoMergeCommitBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+          throws Exception {
+    // Set up change and patchsets.
+    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id childChangeId =
+        changeOps
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+    PatchSet.Id childPatchset1Id =
+        changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+    PatchSet.Id childPatchset2Id =
+        changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+    // Add comment.
+    String commentUuid =
+        newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
+
+    Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+    assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+    CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+    assertThat(portedComment).line().isNull();
+    assertThat(portedComment).side().isNull();
+    assertThat(portedComment).parent().isNull();
+  }
+
+  @Test
+  public void whitespaceOnlyModificationsAreAlsoConsideredWhenPorting() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchset2Id =
+        changeOps.change(changeId).newPatchset().file("myFile").content("\nLine 1\n").create();
+    // Add comment.
+    String commentUuid = newComment(patchset1Id).onLine(1).ofFile("myFile").create();
+
+    CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+    assertThat(portedComment).line().isEqualTo(2);
+  }
+
+  @Test
+  public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid = newComment(patchsetId1).message("Confidential content").create();
+
+    getPortedComment(patchsetId2, commentUuid);
+    gApi.changes()
+        .id(changeId.get())
+        .revision(patchsetId1.get())
+        .comment(commentUuid)
+        .delete(new DeleteCommentInput());
+    CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);
+
+    assertThat(portedComment).message().doesNotContain("Confidential content");
+  }
+
+  @Test
+  public void setOfPortedCommentsCanChangeOnRepeatedCalls() throws Exception {
+    // Set up change and patchsets.
+    Change.Id changeId = changeOps.newChange().create();
+    PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+    // Add comment.
+    String commentUuid1 = newComment(patchsetId1).unresolved().create();
+
+    ImmutableList<CommentInfo> pastPortedComments = flatten(getPortedComments(patchsetId2));
+    // Set the existing comment thread to resolved, so it won't be ported anymore.
+    newComment(patchsetId1).parentUuid(commentUuid1).resolved().create();
+    // Create a new comment which should show up as ported comment.
+    String commentUuid2 = newComment(patchsetId1).create();
+    ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
+
+    // Ensure that results are not cached between calls. This should not be necessary as the diffs
+    // are already cached. If we need to also cache the ported comments in the future, we'll need to
+    // identify ALL situations when the set of ported comments changes.
+    assertThat(portedComments).isNotEqualTo(pastPortedComments);
+    assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid2);
+  }
+
+  private TestCommentCreation.Builder newComment(PatchSet.Id patchsetId) {
+    // Create unresolved comments by default as only those are ported. Tests get override the
+    // unresolved state by explicitly setting it.
+    return changeOps.change(patchsetId.changeId()).patchset(patchsetId).newComment().unresolved();
+  }
+
+  private TestCommentCreation.Builder newDraftComment(PatchSet.Id patchsetId) {
+    // Create unresolved comments by default as only those are ported. Tests get override the
+    // unresolved state by explicitly setting it.
+    return changeOps
+        .change(patchsetId.changeId())
+        .patchset(patchsetId)
+        .newDraftComment()
+        .unresolved();
+  }
+
   private CommentInfo getPortedComment(PatchSet.Id patchsetId, String commentUuid)
       throws RestApiException {
     Map<String, List<CommentInfo>> portedComments = getPortedComments(patchsetId);
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/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index aafe9b9..23bcdec 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -412,17 +412,17 @@
     sender.clear();
     result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.OWNER_REVIEWERS);
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(result.getChangeId(), user, null);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
 
     sender.clear();
     result = pushTo(pushSpec + ",submit,notify=" + NotifyHandling.ALL);
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(result.getChangeId(), user, null);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
 
     sender.clear();
     result = pushTo(pushSpec + ",submit"); // default is notify = ALL
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(result.getChangeId(), user, null);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user, null);
   }
 
   @Test
@@ -442,22 +442,19 @@
     PushOneCommit.Result result =
         pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-to=" + user2.email());
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(
-        result.getChangeId(), user2, RecipientType.TO);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.TO);
 
     sender.clear();
     result =
         pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-cc=" + user2.email());
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(
-        result.getChangeId(), user2, RecipientType.CC);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.CC);
 
     sender.clear();
     result =
         pushTo(pushSpec + ",submit,notify=" + NotifyHandling.NONE + ",notify-bcc=" + user2.email());
     result.assertOkStatus();
-    assertThatEmailsForChangeCreationAndSubmitWereSent(
-        result.getChangeId(), user2, RecipientType.BCC);
+    assertThatEmailsForChangeCreationAndSubmitWereSent(user2, RecipientType.BCC);
   }
 
   private PatchSetApproval getSubmitter(PatchSet.Id patchSetId) throws Exception {
@@ -518,15 +515,15 @@
    *     sent as "To" and sometimes can be sent as "Cc".
    */
   private void assertThatEmailsForChangeCreationAndSubmitWereSent(
-      String changeId, TestAccount expected, @Nullable RecipientType expectedRecipientType) {
+      TestAccount expected, @Nullable RecipientType expectedRecipientType) {
     String expectedEmail = expected.email();
     String expectedFullName = expected.fullName();
     Address expectedAddress = Address.create(expectedFullName, expectedEmail);
     assertThat(sender.getMessages()).hasSize(2);
-    Message message = Iterables.getOnlyElement(sender.getMessages(changeId, "newchange"));
+    Message message = sender.getMessages().get(0);
     assertThat(message.body().contains("review")).isTrue();
     assertAddress(message, expectedAddress, expectedRecipientType);
-    message = Iterables.getOnlyElement(sender.getMessages(changeId, "merged"));
+    message = sender.getMessages().get(1);
     assertThat(message.rcpt()).containsExactly(expectedAddress);
     assertAddress(message, expectedAddress, expectedRecipientType);
     assertThat(message.body().contains("submitted")).isTrue();
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 b156d1b..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
@@ -611,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
@@ -627,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
@@ -663,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
@@ -958,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());
@@ -1281,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);
   }
@@ -1301,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/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index e040860..a6bd5eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -28,7 +28,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
@@ -399,13 +398,13 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
 
-    Message m = Iterables.getOnlyElement(sender.getMessages(r.getChangeId(), "comment"));
+    Message m = messages.get(0);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
     assertThat(m.body()).contains(admin.fullName() + " has posted comments on this change.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertThat(m.body()).contains("Patch Set 1: Code-Review+2");
 
-    m = Iterables.getOnlyElement(sender.getMessages(r.getChangeId(), "newchange"));
+    m = messages.get(1);
     assertThat(m.rcpt()).containsExactly(user.getNameEmail(), observer.getNameEmail());
     assertThat(m.body()).contains("Hello " + user.fullName() + ",\n");
     assertThat(m.body()).contains("I'd like you to do a code review.");
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 88705e1..72453fd 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;
@@ -28,6 +30,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -51,13 +54,12 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -66,7 +68,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;
@@ -80,6 +81,7 @@
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -98,8 +100,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 +658,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 +730,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 +753,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
@@ -944,12 +1022,16 @@
     addComment(r1, "nit: trailing whitespace");
     addComment(r2, "typo: content");
 
-    Map<String, List<CommentInfo>> actual = gApi.changes().id(r2.getChangeId()).comments();
+    Map<String, List<CommentInfo>> actual =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().get();
     assertThat(actual.keySet()).containsExactly(FILE_NAME);
 
     List<CommentInfo> comments = actual.get(FILE_NAME);
     assertThat(comments).hasSize(2);
 
+    // Comment context is disabled by default
+    assertThat(comments.stream().filter(c -> c.contextLines != null)).isEmpty();
+
     CommentInfo c1 = comments.get(0);
     assertThat(c1.author._accountId).isEqualTo(user.id().get());
     assertThat(c1.patchSet).isEqualTo(1);
@@ -966,6 +1048,61 @@
   }
 
   @Test
+  public void listChangeCommentsWithContextEnabled() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+
+    ImmutableList.Builder<String> content = ImmutableList.builder();
+    for (int i = 1; i <= 10; i++) {
+      content.add("line_" + i);
+    }
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                content.build().stream().collect(Collectors.joining("\n")),
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    addCommentOnLine(r2, "nit: please fix", 1);
+    addCommentOnRange(r2, "looks good", commentRangeInLines(2, 5));
+
+    List<CommentInfo> comments =
+        gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+    assertThat(comments).hasSize(2);
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("nit: please fix"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
+  private List<ContextLineInfo> contextLines(String... args) {
+    List<ContextLineInfo> result = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      int lineNbr = Integer.parseInt(args[i]);
+      String contextLine = args[i + 1];
+      ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
+      result.add(info);
+    }
+    return result;
+  }
+
+  @Test
   public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
     PushOneCommit.Result r1 = createChange();
 
@@ -977,12 +1114,12 @@
     addComment(r1, "nit: trailing whitespace");
     addComment(r2, "typo: content");
 
-    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
         .containsExactly("nit: trailing whitespace", "typo: content");
 
     requestScopeOperations.setApiUserAnonymous();
-    comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+    comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
     assertThat(comments.stream().map(c -> c.message).collect(toList()))
         .containsExactly("nit: trailing whitespace", "typo: content");
   }
@@ -1474,26 +1611,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 +1694,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();
@@ -1585,7 +1775,23 @@
   }
 
   private void addComment(PushOneCommit.Result r, String message) throws Exception {
-    addComment(r, message, false, false, null);
+    addComment(r, message, false, false, null, 1, null);
+  }
+
+  private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
+    addComment(r, message, false, false, null, line, null);
+  }
+
+  private void addCommentOnRange(PushOneCommit.Result r, String message, Comment.Range range)
+      throws Exception {
+    addComment(r, message, false, false, null, null, range);
+  }
+
+  private Comment.Range commentRangeInLines(int startLine, int endLine) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.endLine = endLine;
+    return range;
   }
 
   private void addComment(
@@ -1595,12 +1801,25 @@
       Boolean unresolved,
       String inReplyTo)
       throws Exception {
+    addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, 1, null);
+  }
+
+  private void addComment(
+      PushOneCommit.Result r,
+      String message,
+      boolean omitDuplicateComments,
+      Boolean unresolved,
+      String inReplyTo,
+      Integer line,
+      Comment.Range range)
+      throws Exception {
     CommentInput c = new CommentInput();
     c.line = 1;
     c.message = message;
     c.path = FILE_NAME;
     c.unresolved = unresolved;
     c.inReplyTo = inReplyTo;
+    c.range = range;
     ReviewInput in = newInput(c);
     in.omitDuplicateComments = omitDuplicateComments;
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
@@ -1610,11 +1829,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();
   }
@@ -1630,7 +1857,11 @@
   }
 
   private List<CommentInfo> getPublishedCommentsAsList(String changeId) throws Exception {
-    return gApi.changes().id(changeId).commentsAsList();
+    return gApi.changes().id(changeId).commentsRequest().getAsList();
+  }
+
+  private List<CommentInfo> getPublishedCommentsAsList(Change.Id changeId) throws Exception {
+    return gApi.changes().id(changeId.get()).commentsRequest().getAsList();
   }
 
   private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 7570ce9..1a01184 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -124,14 +124,12 @@
     List<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(2);
 
-    FakeEmailSender.Message newPatchsetMessage =
-        Iterables.getOnlyElement(sender.getMessages(changeId, "newpatchset"));
+    FakeEmailSender.Message newPatchsetMessage = messages.get(0);
     assertThat(newPatchsetMessage.body()).contains("new patch set");
     assertThat(newPatchsetMessage.headers().get("Message-ID").toString())
         .doesNotContain("EmailReviewComments");
 
-    FakeEmailSender.Message newCommentsMessage =
-        Iterables.getOnlyElement(sender.getMessages(changeId, "comment"));
+    FakeEmailSender.Message newCommentsMessage = messages.get(1);
     assertThat(newCommentsMessage.body()).contains("has posted comments on this change");
     assertThat(newCommentsMessage.headers().get("Message-ID").toString())
         .contains("EmailReviewComments");
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 975d7ec..0bd6554 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -1175,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();
@@ -1212,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();
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
index db82fd5..9161928 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);
   }
@@ -344,6 +353,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();
 
@@ -686,6 +748,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();
 
@@ -705,10 +820,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();
   }
@@ -724,6 +1222,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/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index c255e61..b3b2f5a 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -1,8 +1,9 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 junit_tests(
     name = "tests",
-    srcs = glob(["*.java"]),
+    srcs = glob(["*Test.java"]),
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
@@ -10,3 +11,9 @@
         "//lib/truth",
     ],
 )
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_cache",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index 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/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
new file mode 100644
index 0000000..d8c6fe2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -0,0 +1,74 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class PersistentCacheFactoryIT extends AbstractDaemonTest {
+
+  @Inject PersistentCacheFactory persistentCacheFactory;
+
+  @ModuleImpl(name = CacheModule.PERSISTENT_MODULE)
+  public static class Module extends AbstractModule {
+
+    @Override
+    protected void configure() {
+      bind(PersistentCacheFactory.class).to(TestCacheFactory.class);
+    }
+  }
+
+  @Override
+  public com.google.inject.Module createModule() {
+    return new Module();
+  }
+
+  @Test
+  public void shouldH2PersistentCacheBeReplaceableByADifferentCacheImplementation() {
+    assertThat(persistentCacheFactory).isInstanceOf(TestCacheFactory.class);
+  }
+
+  public static class TestCacheFactory implements PersistentCacheFactory {
+
+    private final MemoryCacheFactory memoryCacheFactory;
+
+    @Inject
+    TestCacheFactory(MemoryCacheFactory memoryCacheFactory) {
+      this.memoryCacheFactory = memoryCacheFactory;
+    }
+
+    @Override
+    public <K, V> com.google.common.cache.Cache<K, V> build(
+        PersistentCacheDef<K, V> def, CacheBackend backend) {
+      return memoryCacheFactory.build(def, backend);
+    }
+
+    @Override
+    public <K, V> LoadingCache<K, V> build(
+        PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
+      return memoryCacheFactory.build(def, loader, backend);
+    }
+
+    @Override
+    public void onStop(String plugin) {}
+  }
+}
diff --git a/javatests/com/google/gerrit/server/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
index cb29315..8891cd2 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.Comparator.comparing;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyShort;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -31,6 +32,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.PatchList;
@@ -41,6 +43,7 @@
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import java.sql.Timestamp;
 import java.util.Arrays;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Rule;
 import org.junit.Test;
@@ -56,6 +59,9 @@
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
   @Mock private PatchListCache patchListCache;
+  @Mock private CommentsUtil commentsUtil;
+
+  private int uuidCounter = 0;
 
   @Test
   public void commentsAreNotDroppedWhenDiffNotAvailable() throws Exception {
@@ -66,9 +72,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(PatchListNotAvailableException.class);
     ImmutableList<HumanComment> portedComments =
@@ -86,9 +93,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
@@ -98,7 +106,7 @@
   }
 
   @Test
-  public void commentsAreNotDroppedWhenRetrievingCommitSha1sHasUnexpectedError() throws Exception {
+  public void commentsAreNotDroppedWhenRetrievingCommitSha1sHasUnexpectedError() {
     Project.NameKey project = Project.nameKey("myProject");
     Change.Id changeId = Change.id(1);
     Change change = createChange(project, changeId);
@@ -106,9 +114,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenThrow(IllegalStateException.class);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
 
@@ -124,9 +133,10 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     HumanComment comment = createComment(patchset1.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
@@ -147,18 +157,13 @@
     PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache);
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
     // Place the comments on different patchsets to have two different diff requests.
     HumanComment comment1 = createComment(patchset1.id(), "myFile");
     HumanComment comment2 = createComment(patchset2.id(), "myFile");
-    when(patchListCache.getOldId(any(), any(), any())).thenReturn(dummyObjectId);
-    PatchList emptyDiff =
-        new PatchList(
-            dummyObjectId,
-            dummyObjectId,
-            false,
-            ComparisonType.againstOtherPatchSet(),
-            new PatchListEntry[0]);
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    PatchList emptyDiff = getEmptyDiff();
     // Throw an exception on the first diff request but return an actual value on the second.
     when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
         .thenThrow(IllegalStateException.class)
@@ -171,6 +176,29 @@
     assertThat(portedComments).comparingElementsUsing(hasFilePath()).contains("myFile");
   }
 
+  @Test
+  public void commentsWithInvalidPatchsetsAreIgnored() throws Exception {
+    Project.NameKey project = Project.nameKey("myProject");
+    Change.Id changeId = Change.id(1);
+    Change change = createChange(project, changeId);
+    PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+    PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+    // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
+    ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
+
+    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+    HumanComment comment = createComment(patchset1.id(), "myFile");
+    when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+        .thenReturn(Optional.of(dummyObjectId));
+    PatchList emptyDiff = getEmptyDiff();
+    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+        .thenReturn(emptyDiff);
+    ImmutableList<HumanComment> portedComments =
+        commentPorter.portComments(changeNotes, patchset2, ImmutableList.of(comment));
+
+    assertThat(portedComments).isEmpty();
+  }
+
   private Change createChange(Project.NameKey project, Change.Id changeId) {
     return new Change(
         Change.key("changeKey"),
@@ -206,7 +234,7 @@
 
   private HumanComment createComment(PatchSet.Id patchsetId, String filePath) {
     return new HumanComment(
-        new Comment.Key("commentUuid", filePath, patchsetId.get()),
+        new Comment.Key(getUniqueUuid(), filePath, patchsetId.get()),
         Account.id(100),
         new Timestamp(1234),
         (short) 1,
@@ -215,7 +243,20 @@
         true);
   }
 
+  private String getUniqueUuid() {
+    return "commentUuid" + uuidCounter++;
+  }
+
   private Correspondence<HumanComment, String> hasFilePath() {
     return NullAwareCorrespondence.transforming(comment -> comment.key.filename, "hasFilePath");
   }
+
+  private PatchList getEmptyDiff() {
+    return new PatchList(
+        dummyObjectId,
+        dummyObjectId,
+        false,
+        ComparisonType.againstOtherPatchSet(),
+        new PatchListEntry[0]);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 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/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 ba22eff..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
@@ -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 0fb57d3..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 input => this._getAccountSuggestions(input);
-        },
-      },
-      _queryIncludedGroup: {
-        type: Function,
-        value() {
-          return input => this._getGroupSuggestions(input);
-        },
-      },
-      _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-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-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 2504419..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
@@ -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_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-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 6ecdabb..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};
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-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 162caa4..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() {
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/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-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-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-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 ab706e9..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
@@ -231,6 +231,8 @@
         type: Object,
         value: () => new Set(),
       },
+      // line number on the diff which should be scrolled to upon loading
+      _focusLineNum: Number,
     };
   }
 
@@ -744,14 +746,12 @@
         .then(files => files.has(path));
   }
 
-  _initLineOfInterestAndCursor(lineNum, leftSide) {
+  _initLineOfInterestAndCursor(leftSide) {
     this.$.diffHost.lineOfInterest =
       this._getLineOfInterest({
-        lineNum,
         leftSide,
       });
     this._initCursor({
-      lineNum,
       leftSide,
     });
   }
@@ -818,7 +818,7 @@
   }
 
   _initPatchRange() {
-    let lineNum; let leftSide;
+    let leftSide;
     if (this.params.commentId) {
       const comment = this._changeComments.findCommentById(
           this.params.commentId);
@@ -849,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;
@@ -861,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,
@@ -889,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);
@@ -956,7 +957,8 @@
               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 (value.commentLink) {
@@ -1010,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) {
@@ -1208,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 e4dd52e..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);
       });
@@ -226,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));
           });
         });
 
@@ -1218,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');
     });
@@ -1236,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);
     });
@@ -1266,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.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
index 2502e45..8ac47a2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -347,7 +347,7 @@
     // 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
+    return this.root instanceof ShadowRoot && this.root.getSelection
       ? this.root.getSelection()
       : document.getSelection();
   }
@@ -561,7 +561,13 @@
       );
       return;
     }
-    this._createComment(el, lineNum);
+
+    // 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() {
@@ -644,7 +650,7 @@
 
   _createComment(
     lineEl: Element,
-    lineNum: LineNumber,
+    lineNum?: LineNumber,
     side?: Side,
     range?: CommentRange
   ) {
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/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/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
index 538c78a..9eeddcc 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
@@ -18,7 +18,6 @@
 import {GrPluginRestApi} from '../shared/gr-js-api-interface/gr-plugin-rest-api';
 import {GrEventHelper} from './gr-event-helper/gr-event-helper';
 import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
-import {GrPluginActionContext} from '../shared/gr-js-api-interface/gr-plugin-action-context';
 import {ConfigInfo} from '../../types/common';
 
 interface GerritElementExtensions {
@@ -85,7 +84,9 @@
 
 export interface PluginApi {
   _url?: URL;
-  deprecated: PluginDeprecatedApi;
+  popup(): Promise<GrPopupInterface>;
+  popup(moduleName: string): Promise<GrPopupInterface>;
+  popup(moduleName?: string): Promise<GrPopupInterface | null>;
   hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
   getPluginName(): string;
   on(eventName: string, target: any): void;
@@ -93,21 +94,3 @@
   restApi(): GrPluginRestApi;
   eventHelper(element: Node): GrEventHelper;
 }
-
-export interface PluginDeprecatedApi {
-  _loadedGwt(): void;
-  install: () => void;
-  popup(element: Node): GrPopupInterface;
-  onAction(
-    type: string,
-    action: string,
-    callback: (ctx: GrPluginActionContext) => void
-  ): void;
-  panel(extensionpoint: string, callback: (panel: PanelInfo) => void): void;
-  screen(pattern: string, callback: (settings: SettingsInfo) => void): void;
-  settingsScreen(
-    path: string,
-    menu: string,
-    callback: (settings: SettingsInfo) => void
-  ): void;
-}
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.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 113a881..fc0ea79 100644
--- 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
@@ -35,9 +35,9 @@
 } 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 {PaperInputElement} from '@polymer/paper-input/paper-input';
 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.';
 
@@ -344,14 +344,14 @@
     console.warn('received remove event for missing account', toRemove);
   }
 
-  _getNativeInput(paperInput: PaperInputElement) {
+  _getNativeInput(paperInput: PaperInputElementExt) {
     // In Polymer 2 inputElement isn't nativeInput anymore
     return (paperInput.$.nativeInput ||
       paperInput.inputElement) as HTMLTextAreaElement;
   }
 
   _handleInputKeydown(
-    e: CustomEvent<{input: PaperInputElement; keyCode: number}>
+    e: CustomEvent<{input: PaperInputElementExt; keyCode: number}>
   ) {
     const input = this._getNativeInput(e.detail.input);
     if (
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-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-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-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index e856788..873cc4b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -20,7 +20,6 @@
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
 import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
 import {EventType} from '../../plugins/gr-plugin-types.js';
-import {GrPluginActionContext} from './gr-plugin-action-context.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
@@ -353,15 +352,6 @@
     assert.isOk(plugin.attributeHelper());
   });
 
-  test('deprecated.install', () => {
-    assert.notStrictEqual(plugin.popup, plugin.deprecated.popup);
-    assert.notStrictEqual(plugin.onAction, plugin.deprecated.onAction);
-    plugin.deprecated.install();
-    assert.strictEqual(plugin.popup, plugin.deprecated.popup);
-    assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
-    assert.notStrictEqual(plugin.install, plugin.deprecated.install);
-  });
-
   test('getAdminMenuLinks', () => {
     const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
     const getCallbacksStub = sinon.stub(element, '_getEventCallbacks')
@@ -415,56 +405,6 @@
       plugin.popup('some-name');
       assert.isTrue(openStub.calledOnce);
     });
-
-    test('deprecated.popup(element) creates popup with element', () => {
-      const el = document.createElement('div');
-      el.textContent = 'some text here';
-      const openStub = sinon.stub(GrPopupInterface.prototype, 'open');
-      openStub.returns(Promise.resolve({
-        _getElement() {
-          return document.createElement('div');
-        }}));
-      plugin.deprecated.popup(el);
-      assert.isTrue(openStub.calledOnce);
-    });
-  });
-
-  suite('onAction', () => {
-    let change;
-    let revision;
-    let actionDetails;
-
-    setup(() => {
-      change = {};
-      revision = {};
-      actionDetails = {__key: 'some'};
-      sinon.stub(plugin, 'on').callsArgWith(1, change, revision);
-      sinon.stub(plugin, 'changeActions').returns({
-        addTapListener: sinon.stub().callsArg(1),
-        getActionDetails: () => actionDetails,
-      });
-    });
-
-    test('returns GrPluginActionContext', () => {
-      const stub = sinon.stub();
-      plugin.deprecated.onAction('change', 'foo', ctx => {
-        assert.isTrue(ctx instanceof GrPluginActionContext);
-        assert.strictEqual(ctx.change, change);
-        assert.strictEqual(ctx.revision, revision);
-        assert.strictEqual(ctx.action, actionDetails);
-        assert.strictEqual(ctx.plugin, plugin);
-        stub();
-      });
-      assert.isTrue(stub.called);
-    });
-
-    test('other actions', () => {
-      const stub = sinon.stub();
-      plugin.deprecated.onAction('project', 'foo', stub);
-      plugin.deprecated.onAction('edit', 'foo', stub);
-      plugin.deprecated.onAction('branch', 'foo', stub);
-      assert.isFalse(stub.called);
-    });
   });
 
   suite('screen', () => {
@@ -480,18 +420,6 @@
       );
     });
 
-    test('deprecated works', () => {
-      const stub = sinon.stub();
-      const hookStub = {onAttached: sinon.stub()};
-      sinon.stub(plugin, 'hook').returns(hookStub);
-      plugin.deprecated.screen('foo', stub);
-      assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
-      const fakeEl = {style: {display: ''}};
-      hookStub.onAttached.callArgWith(0, fakeEl);
-      assert.isTrue(stub.called);
-      assert.equal(fakeEl.style.display, 'none');
-    });
-
     test('works', () => {
       sinon.stub(plugin, 'registerCustomComponent');
       plugin.screen('foo', 'some-module');
@@ -500,82 +428,11 @@
     });
   });
 
-  suite('panel', () => {
-    let fakeEl;
-    let emulateAttached;
-
-    setup(()=> {
-      fakeEl = {change: {}, revision: {}};
-      const hookStub = {onAttached: sinon.stub()};
-      sinon.stub(plugin, 'hook').returns(hookStub);
-      emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-    });
-
-    test('plugin.panel is deprecated', () => {
-      plugin.panel('rubbish');
-      assert.isTrue(console.error.called);
-    });
-
-    [
-      ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
-      ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
-    ].forEach(([panelName, endpointName]) => {
-      test(`deprecated.panel works for ${panelName}`, () => {
-        const callback = sinon.stub();
-        plugin.deprecated.panel(panelName, callback);
-        assert.isTrue(plugin.hook.calledWith(endpointName));
-        emulateAttached();
-        assert.isTrue(callback.called);
-        const args = callback.args[0][0];
-        assert.strictEqual(args.body, fakeEl);
-        assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
-        assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
-      });
-    });
-  });
-
   suite('settingsScreen', () => {
-    test('plugin.settingsScreen is deprecated', () => {
-      plugin.settingsScreen('rubbish');
-      assert.isTrue(console.error.called);
-    });
-
     test('plugin.settings() returns GrSettingsApi', () => {
       assert.isOk(plugin.settings());
       assert.isTrue(plugin.settings() instanceof GrSettingsApi);
     });
-
-    test('plugin.deprecated.settingsScreen() works', () => {
-      const hookStub = {onAttached: sinon.stub()};
-      sinon.stub(plugin, 'hook').returns(hookStub);
-      const fakeSettings = {};
-      fakeSettings.title = sinon.stub().returns(fakeSettings);
-      fakeSettings.token = sinon.stub().returns(fakeSettings);
-      fakeSettings.module = sinon.stub().returns(fakeSettings);
-      fakeSettings.build = sinon.stub().returns(hookStub);
-      sinon.stub(plugin, 'settings').returns(fakeSettings);
-      const callback = sinon.stub();
-
-      plugin.deprecated.settingsScreen('path', 'menu', callback);
-      assert.isTrue(fakeSettings.title.calledWith('menu'));
-      assert.isTrue(fakeSettings.token.calledWith('path'));
-      assert.isTrue(fakeSettings.module.calledWith('div'));
-      assert.equal(fakeSettings.build.callCount, 1);
-
-      const fakeBody = {};
-      const fakeEl = {
-        style: {
-          display: '',
-        },
-        querySelector: sinon.stub().returns(fakeBody),
-      };
-      // Emulate settings screen attached
-      hookStub.onAttached.callArgWith(0, fakeEl);
-      assert.isTrue(callback.called);
-      const args = callback.args[0][0];
-      assert.strictEqual(args.body, fakeBody);
-      assert.equal(fakeEl.style.display, 'none');
-    });
   });
 });
 
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-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 34ecd9e..4366dd5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -42,7 +42,14 @@
   ) {}
 
   popup(element: Node) {
-    this._popups.push(this.plugin.deprecated.popup(element));
+    this.plugin.popup().then(popApi => {
+      const popupEl = popApi._getElement();
+      if (!popupEl) {
+        throw new Error('Popup element not found');
+      }
+      popupEl.appendChild(element);
+      this._popups.push(popApi);
+    });
   }
 
   hide() {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index e93ffd2..27c9c28 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -33,17 +33,20 @@
     instance = new GrPluginActionContext(plugin);
   });
 
-  test('popup() and hide()', () => {
+  test('popup() and hide()', done => {
     const popupApiStub = {
+      _getElement: sinon.stub().returns(document.createElement('div')),
       close: sinon.stub(),
     };
-    sinon.stub(plugin.deprecated, 'popup').returns(popupApiStub);
-    const el = {};
+    sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
+    const el = document.createElement('span');
     instance.popup(el);
-    assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
-
-    instance.hide();
-    assert.isTrue(popupApiStub.close.called);
+    flush(() => {
+      assert.isTrue(popupApiStub._getElement.called);
+      instance.hide();
+      assert.isTrue(popupApiStub.close.called);
+      done();
+    });
   });
 
   test('textfield', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index c6b3485..043293f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -31,7 +31,6 @@
 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 {GrPluginActionContext} from './gr-plugin-action-context';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 
 import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
@@ -39,23 +38,15 @@
 import {
   EventType,
   HookApi,
-  PanelInfo,
   PluginApi,
-  PluginDeprecatedApi,
   RegisterOptions,
-  SettingsInfo,
   TargetElement,
 } from '../../plugins/gr-plugin-types';
-import {ActionInfo, RequestPayload} from '../../../types/common';
+import {RequestPayload} from '../../../types/common';
 import {HttpMethod} from '../../../constants/constants';
 import {JsApiService} from './gr-js-api-types';
 import {GrChangeActions} from '../../../services/services/gr-rest-api/gr-rest-api';
 
-const PANEL_ENDPOINTS_MAPPING = {
-  CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
-  CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
-};
-
 /**
  * Plugin-provided custom components can affect content in extension
  * points using one of following methods:
@@ -77,8 +68,6 @@
 export type SendCallback = (response: unknown) => void;
 
 export class Plugin implements PluginApi {
-  readonly deprecated: PluginDeprecatedApi;
-
   readonly _url?: URL;
 
   private _domHooks: GrDomHooksManager;
@@ -89,25 +78,6 @@
   private readonly sharedApiElement: JsApiService;
 
   constructor(url?: string) {
-    this.deprecated = {
-      _loadedGwt: () => {},
-      install: () => this.deprecatedInstall(),
-      onAction: (
-        type: string,
-        action: string,
-        callback: (ctx: GrPluginActionContext) => void
-      ) => this.deprecatedOnAction(type, action, callback),
-      panel: (extensionpoint: string, callback: (panel: PanelInfo) => void) =>
-        this.deprecatedPanel(extensionpoint, callback),
-      popup: (el: Element) => this.deprecatedPopup(el),
-      screen: (pattern: string, callback: (settings: SettingsInfo) => void) =>
-        this.deprecatedScreen(pattern, callback),
-      settingsScreen: (
-        path: string,
-        menu: string,
-        callback: (settings: SettingsInfo) => void
-      ) => this.deprecatedSettingsScreen(path, menu, callback),
-    };
     this.sharedApiElement = getSharedApiEl();
     this._domHooks = new GrDomHooksManager(this);
 
@@ -327,25 +297,16 @@
     return new GrEventHelper(element);
   }
 
-  popup(moduleName: string) {
-    if (typeof moduleName !== 'string') {
+  popup(): Promise<GrPopupInterface>;
+
+  popup(moduleName: string): Promise<GrPopupInterface>;
+
+  popup(moduleName?: string): Promise<GrPopupInterface | null> {
+    if (moduleName !== undefined && typeof moduleName !== 'string') {
       console.error('.popup(element) deprecated, use .popup(moduleName)!');
-      return;
+      return Promise.resolve(null);
     }
-    const api = new GrPopupInterface(this, moduleName);
-    return api.open();
-  }
-
-  panel() {
-    console.error(
-      '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
-    );
-  }
-
-  settingsScreen() {
-    console.error(
-      '.settingsScreen() is deprecated! ' + 'Use .settings() instead.'
-    );
+    return new GrPopupInterface(this, moduleName).open();
   }
 
   screen(screenName: string, moduleName?: string) {
@@ -365,142 +326,4 @@
   _getScreenName(screenName: string) {
     return `${this.getPluginName()}-screen-${screenName}`;
   }
-
-  // !!! DEPRECATED !!!
-  // All methods below are deprecated!
-  // TODO: should be removed soon after all core plugins moved away from it.
-
-  deprecatedInstall() {
-    console.info('Installing deprecated APIs is deprecated!');
-    const deprecatedThis = (this as unknown) as PluginDeprecatedApi;
-    deprecatedThis._loadedGwt = this.deprecated._loadedGwt;
-    deprecatedThis.onAction = this.deprecated.onAction;
-    deprecatedThis.panel = this.deprecated.panel;
-    deprecatedThis.popup = this.deprecated.popup;
-    deprecatedThis.screen = this.deprecated.screen;
-    deprecatedThis.settingsScreen = this.deprecated.settingsScreen;
-  }
-
-  deprecatedPopup(el: Element): GrPopupInterface {
-    console.warn(
-      'plugin.deprecated.popup() is deprecated, ' + 'use plugin.popup() insted!'
-    );
-    if (!el) {
-      throw new Error('Popup contents not found');
-    }
-    const api = new GrPopupInterface(this);
-    api.open().then(api => {
-      const popupEl = api._getElement();
-      if (!popupEl) {
-        throw new Error('Popup element not found');
-      }
-      popupEl.appendChild(el);
-    });
-    return api;
-  }
-
-  deprecatedOnAction(
-    type: string,
-    action: string,
-    callback: (ctx: GrPluginActionContext) => void
-  ) {
-    console.warn(
-      'plugin.deprecated.onAction() is deprecated,' +
-        ' use plugin.changeActions() instead!'
-    );
-    if (type !== 'change' && type !== 'revision') {
-      console.warn(`${type} actions are not supported.`);
-      return;
-    }
-    this.on(EventType.SHOW_CHANGE, (change, revision) => {
-      const details: ActionInfo = this.changeActions().getActionDetails(action);
-      if (!details) {
-        console.warn(
-          `${this.getPluginName()} onAction error: ${action} not found!`
-        );
-        return;
-      }
-      if (!details.__key) {
-        console.warn(
-          `${this.getPluginName()} onAction error: ${action} has no key!`
-        );
-        return;
-      }
-      this.changeActions().addTapListener(details.__key, () => {
-        callback(new GrPluginActionContext(this, details, change, revision));
-      });
-    });
-  }
-
-  deprecatedScreen(
-    pattern: string,
-    callback: (settings: SettingsInfo) => void
-  ) {
-    console.warn(
-      'plugin.deprecated.screen is deprecated,' + ' use plugin.screen instead!'
-    );
-    this.hook(this._getScreenName(pattern)).onAttached(el => {
-      el.style.display = 'none';
-      callback({
-        body: el,
-        token: el.token,
-        onUnload: () => {},
-        setTitle: () => {},
-        setWindowTitle: () => {},
-        show: () => {
-          el.style.display = 'initial';
-        },
-      });
-    });
-  }
-
-  deprecatedSettingsScreen(
-    path: string,
-    menu: string,
-    callback: (settings: SettingsInfo) => void
-  ) {
-    console.warn('.settingsScreen() is deprecated! Use .settings() instead.');
-    const hook = this.settings().title(menu).token(path).module('div').build();
-    hook.onAttached(el => {
-      el.style.display = 'none';
-      const body = el.querySelector('div');
-      if (!body) return;
-      callback({
-        body,
-        onUnload: () => {},
-        setTitle: () => {},
-        setWindowTitle: () => {},
-        show: () => {
-          el.style.display = 'initial';
-        },
-      });
-    });
-  }
-
-  deprecatedPanel(
-    extensionpoint: string,
-    callback: (panel: PanelInfo) => void
-  ) {
-    console.warn(
-      '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
-    );
-    let endpoint;
-    for (const [key, value] of Object.entries(PANEL_ENDPOINTS_MAPPING)) {
-      if (key === extensionpoint) endpoint = value;
-    }
-    if (!endpoint) {
-      console.warn(`.panel ${extensionpoint} not supported!`);
-      return;
-    }
-    this.hook(endpoint).onAttached(el =>
-      callback({
-        body: el,
-        p: {
-          CHANGE_INFO: el.change,
-          REVISION_INFO: el.revision,
-        },
-        onUnload: () => {},
-      })
-    );
-  }
 }
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-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index a140f50..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
@@ -1125,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
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 18c24d2..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
@@ -75,6 +75,9 @@
   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';
@@ -534,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>;
@@ -601,4 +608,58 @@
     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 3b738a4..fbc62e2 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -135,7 +135,7 @@
 
       /* Stopgap solution until we remove hidden$ attributes. */
 
-      :host[hidden],
+      :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/types/common.ts b/polygerrit-ui/app/types/common.ts
index e131b4f..cc74ef1 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -142,6 +142,10 @@
  */
 export type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
 
+export type Reviewers = {
+  REVIEWER?: AccountInfo[];
+  CC?: AccountInfo[];
+};
 interface LabelCommonInfo {
   optional?: boolean; // not set if false
 }
@@ -211,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[];
@@ -1122,6 +1123,7 @@
   edit_a?: number[][];
   edit_b?: number[][];
   due_to_rebase?: boolean;
+  due_to_move?: boolean;
   skip?: string;
   common?: string;
   keyLocation?: boolean;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 44178c4..970fa21 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -18,6 +18,7 @@
 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;
@@ -60,6 +61,14 @@
 }
 
 /**
+ * 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).
@@ -117,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 298ae9d..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) {
+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(', ');
 }