Merge "A11y - Keyboard focus moves only to interactive elements on Dashboard"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 47afdba..8509b1f 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1425,8 +1425,7 @@
 any of their groups is used.
 
 This limit applies not only to the link:cmd-query.html[`gerrit query`]
-command, but also to the web UI results pagination size in the new
-PolyGerrit UI and, limited to the full project list, in the old GWT UI.
+command, but also to the web UI results pagination size.
 
 
 [[capability_readAs]]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 8ad8669..1f4fd9c 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -828,6 +828,49 @@
 +
 If 0 or negative, disk storage for the cache is disabled.
 
+[[cache.name.expireAfterWrite]]cache.<name>.expireAfterWrite::
++
+Duration after which a cached value will be evicted and not
+read anymore.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+Disabled by default.
+
+[[cache.name.refreshAfterWrite]]cache.<name>.refreshAfterWrite::
++
+Duration after which we asynchronously refresh the cached value.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory. Defaults to 15 minutes.
+
+[[cache.refreshThreadPoolSize]]cache.refreshThreadPoolSize::
++
+Number of threads that are available to refresh cached values that became
+out of date. This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory
++
+Refreshes will only be scheduled on this executor if the values are
+out of sync.
+The check if they are is cheap and always happens on the thread that
+inquires for a cached value.
++
+Defaults to 2.
+
 ==== [[cache_names]]Standard Caches
 
 cache `"accounts"`::
@@ -1125,22 +1168,6 @@
 +
 Default is true, enabled.
 
-[[cache.projects.checkFrequency]]cache.projects.checkFrequency::
-+
-How often project configuration should be checked for update from Git.
-Gerrit Code Review caches project access rules and configuration in
-memory, checking the refs/meta/config branch every checkFrequency
-minutes to see if a new revision should be loaded and used for future
-access. Values can be specified using standard time unit abbreviations
-('ms', 'sec', 'min', etc.).
-+
-If set to 0, checks occur every time, which may slow down operations.
-If set to 'disabled' or 'off', no check will ever be done.
-Administrators may force the cache to flush with
-link:cmd-flush-caches.html[gerrit flush-caches].
-+
-Default is 5 minutes.
-
 [[cache.projects.loadOnStartup]]cache.projects.loadOnStartup::
 +
 If the project cache should be loaded during server startup.
@@ -2215,6 +2242,25 @@
 +
 By default false.
 
+[[gerrit.xframeOption]]gerrit.xframeOption::
++
+Add link:https://tools.ietf.org/html/rfc7034[`X-Frame-Options`] header to all HTTP
+responses. The `X-Frame-Options` HTTP response header can be used to indicate
+whether or not a browser should be allowed to render a page in a
+`<frame>`, `<iframe>`, `<embed>` or `<object>`.
++
+Available values:
++
+1. ALLOW - The page can be displayed in a frame.
+2. SAMEORIGIN - The page can only be displayed in a frame on the same origin as the page itself.
++
+If link:#gerrit.canLoadInIFrame is set to false this option is ignored and the
+`X-Frame-Options` header is always set to `DENY`.
+Setting this option to `ALLOW` will cause the `X-Frame-Options` header to be omitted
+the the page can be displayed in a frame.
++
+By default SAMEORIGIN.
+
 [[gerrit.cdnPath]]gerrit.cdnPath::
 +
 Path prefix for PolyGerrit's static resources if using a CDN.
@@ -2238,6 +2284,36 @@
 +
 Defaults to the full hostname of the Gerrit server.
 
+[[gerrit.experimentalRollingUpgrade]]gerrit.experimentalRollingUpgrade::
++
+Enable Gerrit rolling upgrade to the next version.
+For example if Gerrit v3.1 is version N (All-Projects:refs/meta/version=181)
+then its next version N+1 is v3.2 (All-Projects:refs/meta/version=183).
+Allow Gerrit to start even if the underlying schema version has been bumped to
+the next Gerrit version.
++
+Set to true if Gerrit is installed in
+[high-availability configuration](https://gerrit.googlesource.com/plugins/high-availability/+/refs/heads/master/README.md)
+during the rolling upgrade to the next version.
++
+By default false.
++
+The rolling upgrade process, at high level, assumes that Gerrit is installed
+on two or more nodes sharing the repositories over NFS. The upgrade is composed
+of the following steps:
++
+1. Set gerrit.experimentalRollingUpgrade to true on all Gerrit masters
+2. Set the first master unhealthy
+3. Shutdown the first master and [upgrade](install.html#init) to the next version
+4. Startup the first master, wait for the online reindex to complete
+5. Verify the the first master upgrade is successful and online reindex is complete
+6. Set the first master healthy
+7. Repeat steps 2. to 6. for all the other Gerrit nodes
++
+[WARNING]
+Rolling upgrade may or may not be possible depending on the changes introduced
+by the target version of the upgrade. Refer to the release notes and check whether
+the rolling upgrade is possible or not and the associated constraints.
 
 [[gerrit.serverId]]gerrit.serverId::
 +
@@ -2919,7 +2995,7 @@
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
-online schema upgrades.
+online schema upgrades, and also for offline reindexing.
 +
 If not set or set to a zero, defaults to the number of logical CPUs as returned
 by the JVM. If set to a negative value, defaults to a direct executor.
@@ -3736,6 +3812,18 @@
 +
 By default, 1.
 
+[[notedb.changes.sequenceBatchSize]]notedb.changes.sequenceBatchSize::
++
+The next available change sequence number is stored as UTF-8 text in a
+blob pointed to by the `refs/sequences/changes` ref in the `All-Projects`
+repository. Multiple processes share the same sequence by incrementing
+the counter using normal git ref updates. To amortize the cost of these
+ref updates, processes increment the counter by a larger number and
+hand out numbers from that range in memory until they run out. This
+configuration parameter controls the size of the change ID batch that
+each process retrieves at once.
++
+By default, 20.
 
 [[oauth]]
 === Section oauth
@@ -5347,7 +5435,13 @@
 login as the 'Gerrit Code Review' user, required for the link:cmd-suexec.html[suexec]
 command.
 
-The format is one Base-64 encoded public key per line.
+The format is one Base-64 encoded public key per line with optional comment, e.g.:
+----
+# Comments allowed at start of line
+AAAAC3...51R== john@example.net
+# Another comment
+AAAAB5...21S== jane@example.net
+----
 
 === Configurable Parameters
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index c298ba1..a01df50 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -316,27 +316,35 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-[[content_merge]]
-- 'mergeContent': Defines whether Gerrit will try to
+[[content_merge]]submit.mergeContent::
++
+Defines whether Gerrit will try to
 do a content merge when a path conflict occurs. Valid values are
 'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
 be modified by any project owner through the project console, `Browse`
 > `Repositories` > my/project > `Allow content merges`.
 
-- 'action': Defines the link:#submit-type[submit type].  Valid
+[[submit.action]]submit.action::
++
+Defines the link:#submit-type[submit type].  Valid
 values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
 'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
 
-- 'matchAuthorToCommitterDate': Defines whether to the author date will be changed to match the
-submitter date upon submit, so that git log shows when the change was submitted instead of when the
-author last committed. Valid values are 'true', 'false', or 'INHERIT'. The default is 'INHERIT'.
-This option only takes effect in submit strategies which already modify the commit, i.e.
-Cherry Pick, Rebase Always, and (perhaps) Rebase If Necessary.
+[[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
++
+Defines whether the author date will be changed to match the submitter date upon submit, so that
+git log shows when the change was submitted instead of when the author last committed. Valid
+values are 'true', 'false', or 'INHERIT'. The default is 'INHERIT'. This option only takes effect
+in submit strategies which already modify the commit, i.e. Cherry Pick, Rebase Always, and
+(when rebase is necessary) Rebase If Necessary.
 
-- 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
-Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
-commit. If this option is set to 'true' the merge would fail. An empty commit is still allowed as
-the initial commit on a branch.
+[[submit.rejectEmptyCommit]]submit.rejectEmptyCommit::
++
+Defines whether empty commits should be rejected when a change is merged. When using
+link:#submit.action[submit action] Cherry Pick, Rebase If Necessary or Rebase Always changes may
+become empty upon submit, since the rebase|cherry-pick can lead to an empty commit. If this option
+is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
+initial commit on a branch.
 
 [[submit-type]]
 ==== Submit Type
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 23ecd67..477641b 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -144,7 +144,7 @@
   and everyone can comment and raise concerns.
 * Design docs should stay open for a minimum of 10 calendar days so
   that everyone has a fair chance to join the review.
-* Within 14 calendar days the contributor should hear back from the
+* Within 30 calendar days the contributor should hear back from the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
   whether the proposed feature is in scope of the project and if it can
   be accepted.
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index e658910..7954c54 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -132,7 +132,7 @@
 
 For proposed features the contributor should hear back from the
 link:dev-processes.html#steering-committee[engineering steering
-committee] within 14 calendar days whether the proposed feature is in
+committee] within 30 calendar days whether the proposed feature is in
 scope of the project and if it can be accepted.
 
 [[watch-designs]]
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 8ab3d62..c8c2dff 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -391,6 +391,10 @@
 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.extensions.events.AccountActivationListener`:
++
+User account got activated or deactivated
+
 * `com.google.gerrit.extensions.events.LifecycleListener`:
 +
 Plugin start and stop
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 3699a18..b7dd259 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -15,7 +15,7 @@
 * ensuring timely design reviews
 * ensuring that new features are compatible with the project vision and
   are well aligned with other features (give feedback on new
-  link:dev-design-docs.html[design docs] within 14 calendar days)
+  link:dev-design-docs.html[design docs] within 30 calendar days)
 * approving/rejecting link:dev-design-docs.html[designs], vetoing new
   features
 * assigning link:dev-roles.html#mentor[mentors] for approved features
diff --git a/Documentation/error-no-new-changes.txt b/Documentation/error-no-new-changes.txt
index 10575fa..6434b2f 100644
--- a/Documentation/error-no-new-changes.txt
+++ b/Documentation/error-no-new-changes.txt
@@ -23,8 +23,8 @@
 this simply paste the commit ID in the Gerrit Web UI into the search
 field. Details about how to search in Gerrit are explained link:user-search.html[here].
 
-Please note that each commit can really be pushed only once. This
-means:
+Please note that generally it only makes sense for each commit to
+be pushed only once. This means:
 
 . you cannot push a commit again even if the change for which the
   commit was pushed before was abandoned (but you may restore the
@@ -32,21 +32,32 @@
 . you cannot reset a change to an old patch set by pushing the old
   commit for this change again
 . if a commit was pushed to one branch you cannot push this commit
-  to another branch in project scope.
+  to another branch in project scope (see link:user-upload.html#base[exception]).
 . if a commit was pushed directly to a branch (without going through
   code review) you cannot push this commit once again for code
   review (please note that in this case searching by the commit ID
-  in the Gerrit Web UI will not find any change)
+  in the Gerrit Web UI will not find any change), see
+  link:user-upload.html#base[exception].
 
 If you need to re-push a commit you may rewrite this commit by
-link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[amending,role=external,window=_blank] it or doing an interactive link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase,role=external,window=_blank]. By rewriting the
-commit you actually create a new commit (with a new commit ID in
-project scope) which can then be pushed to Gerrit.
+link:http://www.kernel.org/pub/software/scm/git/docs/git-commit.html[amending,role=external,window=_blank]
+it or doing an interactive
+link:http://www.kernel.org/pub/software/scm/git/docs/git-rebase.html[git rebase,role=external,window=_blank],
+or see link:user-upload.html#base[exception,role=external,window=_blank].
+By rewriting the commit you actually create a new commit
+(with a new commit ID in project scope) which can then be pushed to Gerrit.
 
 If you are pushing the new change to the same destination branch as
 the old commit (case 1 above), you also need to replace it with a new
 Change-Id, otherwise the push will fail with another error message.
 
+Sometimes a change no longer makes sense to be destined for a specific
+branch, and instead of trying to re-push the commit for a different
+branch, it makes more sense to move the change to the preferred branch
+(where it will now likely need a rebase). Moving the change instead of
+pushing a rebased commit to the preferred branch helps to retain code
+review comments and any previous patchsets on the original change.
+
 == Fast-forward merges
 
 You will also encounter this error if you did a Fast-forward merge
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 75ad9c2..b91adbb 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -444,6 +444,23 @@
 [NOTE]
 Never rebase commits that are already part of a central branch.
 
+[[move]]
+== Move a Change
+
+Changes can be link:rest-api-changes.html#move-change[moved] to a desired
+destination branch in the same project. This is useful in cases where
+development activity switches from one branch to another and there is a
+need to move open changes on the inactive branch to the new active one.
+Another useful case is to move changes from a newer branch back to an older
+bugfix branch where an issue first appeared.
+
+Users can move a change only if they have link:access-control.html#category_abandon[
+abandon permission] on the change and link:access-control.html#category_push[
+push permission] on the destination branch.
+
+The move operation will not update the change's parent and users will have
+to link:#rebase[rebase] the change. Also, merge commits cannot be moved.
+
 [[abandon]]
 [[restore]]
 == Abandon/Restore a Change
@@ -561,7 +578,19 @@
 ----
   $ git push origin HEAD:refs/for/master%ready
 ----
-Alternatively, click *Start Review* from the Change screen.
+There are two options for marking the change ready for review from the Change
+screen:
+
+1. Click *Start Review* (the primary action *Reply* is renamed when in WIP
+state).
++
+This will open the reply-modal and allow you to add reviewers and/or CC
+before you start review.
+
+2. Click button *Mark As Active*.
++
+This will only change the state from WIP to ready, without opening the
+reply-modal.
 
 Change owners, project owners, site administrators and members of a group that
 was granted "Toggle Work In Progress state" permission can mark changes as
@@ -615,8 +644,7 @@
 exposing secret details.
 
 [[private-changes-pitfalls]]
-Pitfalls
-===
+=== Pitfalls
 
 If private changes are used, be aware of the following pitfalls:
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 97c2548..956a94d 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -279,36 +279,6 @@
 ----
 
 
-[[es6-promise]]
-es6-promise
-
-* es6-promise
-
-[[es6-promise_license]]
-----
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[Polymer-2018]]
 Polymer-2018
 
@@ -747,37 +717,6 @@
 ----
 
 
-[[whatwg-fetch]]
-whatwg-fetch
-
-* whatwg-fetch
-
-[[whatwg-fetch_license]]
-----
-Copyright (c) 2014-2016 GitHub, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index c1dfb94..d561596 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3221,36 +3221,6 @@
 ----
 
 
-[[es6-promise]]
-es6-promise
-
-* es6-promise
-
-[[es6-promise_license]]
-----
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[Polymer-2018]]
 Polymer-2018
 
@@ -3689,37 +3659,6 @@
 ----
 
 
-[[whatwg-fetch]]
-whatwg-fetch
-
-* whatwg-fetch
-
-[[whatwg-fetch_license]]
-----
-Copyright (c) 2014-2016 GitHub, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 7e6799be..3040348 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -64,6 +64,7 @@
 * `caches/memory_eviction_count`: Memory eviction count.
 * `caches/disk_cached`: Disk entries used by persistent cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
+* `caches/refresh_count`: The number of refreshes per cache with an indicator if a reload was necessary.
 
 === Change
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 0505dd2..89758a0 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -110,6 +110,13 @@
 normally run `gerrit.war daemon` with an `-Xmx` flag, pass that to the migration
 tool as well.
 
+[NOTE]
+Note that by appending `--reindex false` to the above command, you can skip the
+lengthy, implicit reindexing step of the migration. This is useful if you plan
+to perform further Gerrit upgrades while the server is offline and have to
+reindex later anyway (E.g.: a follow-up upgrade to Gerrit 3.2 or newer, which
+requires to reindex changes anyway).
+
 *Advantages*
 
 * Much faster than online; can use all available CPUs, since no live traffic
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 1ce1d61..adb5d20 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -11,34 +11,27 @@
 [[loading]]
 == Plugin loading and initialization
 
-link:js-api.html#_entry_point[Entry point] for the plugin and the loading method
-is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports,role=external,window=_blank]
-spec.
+link:js-api.html#_entry_point[Entry point] for the plugin.
 
-* The plugin provides pluginname.html, and can be a standalone file or a static
+* The plugin provides pluginname.js, and can be a standalone file or a static
   asset in a jar as a link:dev-plugins.html#deployment[Web UI plugin].
-* pluginname.html contains a `dom-module` tag with a script that uses
-  `Gerrit.install()`. There should only be single `Gerrit.install()` per file.
-* PolyGerrit imports pluginname.html along with all required resources defined in it
-  (fonts, styles, etc).
-* For standalone plugins, the entry point file is a `pluginname.html` file
+* pluginname.js contains a call to `Gerrit.install()`. There should
+  only be single `Gerrit.install()` per file.
+* PolyGerrit imports pluginname.js.
+* For standalone plugins, the entry point file is a `pluginname.js` file
   located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
   plugin name.
 
 Note: Code examples target modern browsers (Chrome, Firefox, Safari, Edge).
 
-Here's a recommended starter `myplugin.html`:
+Here's a recommended starter `myplugin.js`:
 
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      'use strict';
+``` js
+Gerrit.install(plugin => {
+  'use strict';
 
-      // Your code here.
-    });
-  </script>
-</dom-module>
+  // Your code here.
+});
 ```
 
 [[low-level-api-concepts]]
@@ -103,34 +96,31 @@
 === Styling DOM Elements
 
 A plugin may provide Polymer's
-https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[style
+https://polymer-library.polymer-project.org/3.0/docs/devguide/style-shadow-dom[style
 modules,role=external,window=_blank] to style individual endpoints using
 `plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
-as a standalone `<dom-module>` defined in the same .html file.
+as a standalone `<dom-module>` defined in the same .js file.
+
+See `samples/theme-plugin.js` for examples.
 
 Note: TODO: Insert link to the full styling API.
 
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('change-metadata', 'some-style-module');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="some-style-module">
-  <style>
+``` js
+const styleElement = document.createElement('dom-module');
+styleElement.innerHTML =
+ `<template>
+    <style>
     html {
-      --change-metadata-label-status: {
-        display: none;
-      }
-      --change-metadata-strategy: {
-        display: none;
-      }
+      --primary-text-color: red;
     }
-  </style>
-</dom-module>
+   </style>
+ </template>`;
+
+styleElement.register('some-style-module');
+
+Gerrit.install(plugin => {
+  plugin.registerStyleModule('change-metadata', 'some-style-module');
+});
 ```
 
 [[high-level-api-concepts]]
@@ -152,11 +142,11 @@
 `plugin.attributeHelper(element)`
 
 Alternative for
-link:https://www.polymer-project.org/1.0/docs/devguide/data-binding[Polymer data
+link:https://polymer-library.polymer-project.org/3.0/docs/devguide/data-binding[Polymer data
 binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
-See `samples/bind-parameters.html` for examples on both Polymer data bindings
+See `samples/bind-parameters.js` for examples on both Polymer data bindings
 and `attibuteHelper` usage.
 
 === eventHelper
@@ -253,26 +243,29 @@
 
 Here's the recommended approach that uses Polymer for generating custom elements:
 
-``` html
-<dom-module id="some-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerCustomComponent(
-        'change-view-integration', 'some-ci-module');
-    });
-  </script>
-</dom-module>
+``` js
+class SomeCiModule extends Polymer.Element {
+  static get is() {
+    return "some-ci-module";
+  }
+  static get template() {
+    return Polymer.html`
+      Sample link: <a href="http://some.com/foo">Foo</a>
+    `;
+  }
+}
 
-<dom-module id="some-ci-module">
-  <template>
-    Sample link: <a href="http://some.com/foo">Foo</a>
-  </template>
-  <script>
-    Polymer({is: 'some-ci-module'});
-  </script>
-</dom-module>
+// Register this element
+customElements.define(SomeCiModule.is, SomeCiModule);
+
+// Install the plugin
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent('change-view-integration', 'some-ci-module');
+});
 ```
 
+See `samples/` for more examples.
+
 Here's a minimal example that uses low-level DOM Hooks API for the same purpose:
 
 ``` js
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4006d1d..de6d6be 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -4471,7 +4471,7 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/test.submit_type HTTP/1.0
-  Content-Type: text/plain; charset-UTF-8
+  Content-Type: text/plain; charset=UTF-8
 
   submit_type(cherry_pick).
 ----
@@ -4502,7 +4502,7 @@
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/test.submit_rule?filters=SKIP HTTP/1.0
-  Content-Type: text/plain; charset-UTF-8
+  Content-Type: text/plain; charset=UTF-8
 
   submit_rule(submit(R)) :-
     R = label('Any-Label-Name', reject(_)).
@@ -6191,7 +6191,7 @@
 Notify handling that defines to whom email notifications should be sent
 after the cherry-pick. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `NONE`.
+If not set, the default is `ALL`.
 |`notify_details`   |optional|
 Additional information about whom to notify about the update as a map
 of recipient type to link:#notify-info[NotifyInfo] entity.
diff --git a/README.md b/README.md
index a76dac6..084f2b0 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,8 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
-[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-master/)
+[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-master/)
+![Maven Central](https://img.shields.io/maven-central/v/com.google.gerrit/gerrit-war)
 
 ## Objective
 
diff --git a/WORKSPACE b/WORKSPACE
index 555f5b1..91eef76 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -60,8 +60,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "d0c4bb8b902c1658f42eb5563809c70a06e46015d64057d25560b0eb4bdc9007",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/1.5.0/rules_nodejs-1.5.0.tar.gz"],
+    sha256 = "84abf7ac4234a70924628baa9a73a5a5cbad944c4358cf9abdb4aab29c9a5b77",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/1.7.0/rules_nodejs-1.7.0.tar.gz"],
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -82,6 +82,7 @@
 # https://github.com/google/closure-templates/pull/155
 rules_closure_dependencies(
     omit_aopalliance = True,
+    omit_bazel_skylib = True,
     omit_javax_inject = True,
     omit_rules_cc = True,
 )
@@ -91,10 +92,10 @@
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "b34cbe1a7514f5f5487c3bfee7340a4496713ddf4f119f7a225583d6cafd793a",
+    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
     urls = [
-        "https://storage.googleapis.com/bazel-mirror/github.com/bazelbuild/rules_go/releases/download/v0.21.1/rules_go-v0.21.1.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.21.1/rules_go-v0.21.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
     ],
 )
 
@@ -106,8 +107,11 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "3c681998538231a2d24d0c07ed5a7658cb72bfb5fd4bf9911157c0e9ac6a2687",
-    urls = ["https://github.com/bazelbuild/bazel-gazelle/releases/download/0.17.0/bazel-gazelle-0.17.0.tar.gz"],
+    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+    ],
 )
 
 load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
@@ -222,26 +226,6 @@
     sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
 )
 
-FLOGGER_VERS = "0.5.1"
-
-maven_jar(
-    name = "flogger",
-    artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-    sha1 = "71d1e2cef9cc604800825583df56b8ef5c053f14",
-)
-
-maven_jar(
-    name = "flogger-log4j-backend",
-    artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-    sha1 = "5e2794b75c88223f263f1c1a9d7ea51e2dc45732",
-)
-
-maven_jar(
-    name = "flogger-system-backend",
-    artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-    sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
-)
-
 maven_jar(
     name = "gson",
     artifact = "com.google.code.gson:gson:2.8.5",
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange-body.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange-body.json
new file mode 100644
index 0000000..670aa9f
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange-body.json
@@ -0,0 +1,5 @@
+{
+  "labels": {
+    "Code-Review": 2
+  }
+}
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
new file mode 100644
index 0000000..3577a6a
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
@@ -0,0 +1,6 @@
+[
+  {
+    "url": "http://HOSTNAME:HTTP_PORT/a/changes/",
+    "number": "NUMBER"
+  }
+]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
index c267ab3..b4ee549 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
@@ -1,6 +1,6 @@
 [
   {
     "url": "http://HOSTNAME:HTTP_PORT/a/changes/",
-    "project": "_PROJECT"
+    "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
index 53b947a..3577a6a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
@@ -1,6 +1,6 @@
 [
   {
     "url": "http://HOSTNAME:HTTP_PORT/a/changes/",
-    "number": "_NUMBER"
+    "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
new file mode 100644
index 0000000..a371757
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
@@ -0,0 +1,5 @@
+[
+  {
+    "url": "http://HOSTNAME:HTTP_PORT/a/changes/"
+  }
+]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ApproveChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ApproveChange.scala
new file mode 100644
index 0000000..fe46bd6
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ApproveChange.scala
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.scenarios
+
+import io.gatling.core.Predef.{atOnceUsers, _}
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+import io.gatling.http.Predef.http
+
+class ApproveChange extends GerritSimulation {
+  private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+  private var createChange: Option[CreateChange] = None
+
+  def this(createChange: CreateChange) {
+    this()
+    this.createChange = Some(createChange)
+  }
+
+  val test: ScenarioBuilder = scenario(unique)
+      .feed(data)
+      .exec(session => {
+        if (createChange.nonEmpty) {
+          session.set("number", createChange.get.number)
+        } else {
+          session
+        }
+      })
+      .exec(http(unique)
+          .post("${url}${number}/revisions/current/review")
+          .body(ElFileBody(body)).asJson)
+
+  setUp(
+    test.inject(
+      atOnceUsers(1)
+    )).protocols(httpProtocol)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala
index 57e6bcd..c7fb8ed 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala
@@ -21,26 +21,31 @@
 
 import scala.concurrent.duration._
 
-class CreateChange extends GerritSimulation {
+class CreateChange extends ProjectSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val default: String = name
   private val numberKey = "_number"
+  var number = 0
 
   override def relativeRuntimeWeight = 2
 
-  private val test: ScenarioBuilder = scenario(unique)
+  def this(default: String) {
+    this()
+    this.default = default
+  }
+
+  val test: ScenarioBuilder = scenario(unique)
       .feed(data)
       .exec(httpRequest
           .body(ElFileBody(body)).asJson
           .check(regex("\"" + numberKey + "\":(\\d+),").saveAs(numberKey)))
       .exec(session => {
-        deleteChange.number = Some(session(numberKey).as[Int])
+        number = session(numberKey).as[Int]
         session
       })
 
   private val createProject = new CreateProject(default)
   private val deleteProject = new DeleteProject(default)
-  private val deleteChange = new DeleteChange
+  private val deleteChange = new DeleteChange(this)
 
   setUp(
     createProject.test.inject(
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteChange.scala
index 1b3bbc1..aa6fe0d 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteChange.scala
@@ -21,15 +21,20 @@
 
 class DeleteChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  var number: Option[Int] = None
+  private var createChange: Option[CreateChange] = None
 
   override def relativeRuntimeWeight = 2
 
+  def this(createChange: CreateChange) {
+    this()
+    this.createChange = Some(createChange)
+  }
+
   val test: ScenarioBuilder = scenario(unique)
       .feed(data)
       .exec(session => {
-        if (number.nonEmpty) {
-          session.set("number", number.get)
+        if (createChange.nonEmpty) {
+          session.set("number", createChange.get.number)
         } else {
           session
         }
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 b427c0d..5d6176d 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
@@ -66,7 +66,8 @@
       val precedes = replaceKeyWith("_number", 0, number.toString)
       replaceProperty("number", 1, precedes)
     case ("project", project) =>
-      val precedes = replaceKeyWith("_project", name, project.toString)
+      var precedes = replaceKeyWith("_project", name, project.toString)
+      precedes = replaceOverride(precedes)
       replaceProperty("project", precedes)
     case ("entries", entries) =>
       replaceProperty("projects_entries", "1", entries.toString)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
new file mode 100644
index 0000000..2f67274
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
@@ -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.scenarios
+
+import io.gatling.core.Predef.{atOnceUsers, _}
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+import io.gatling.http.Predef.http
+
+import scala.concurrent.duration._
+
+class SubmitChange extends GerritSimulation {
+  private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+  private val default: String = name
+
+  private val test: ScenarioBuilder = scenario(unique)
+      .feed(data)
+      .exec(session => {
+        session.set("number", createChange.number)
+      })
+      .exec(http(unique).post("${url}${number}/submit"))
+
+  private val createProject = new CreateProject(default)
+  private val createChange = new CreateChange(default)
+  private val approveChange = new ApproveChange(createChange)
+  private val deleteProject = new DeleteProject(default)
+
+  setUp(
+    createProject.test.inject(
+      nothingFor(stepWaitTime(createProject) seconds),
+      atOnceUsers(1)
+    ),
+    createChange.test.inject(
+      nothingFor(stepWaitTime(createChange) seconds),
+      atOnceUsers(1)
+    ),
+    approveChange.test.inject(
+      nothingFor(stepWaitTime(approveChange) seconds),
+      atOnceUsers(1)
+    ),
+    test.inject(
+      nothingFor(stepWaitTime(this) seconds),
+      atOnceUsers(1)
+    ),
+    deleteProject.test.inject(
+      nothingFor(stepWaitTime(deleteProject) seconds),
+      atOnceUsers(1)
+    ),
+  ).protocols(httpProtocol)
+}
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 541e479..9d8bc57 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -39,6 +39,7 @@
     "//lib:gson",
     "//lib:guava-retrying",
     "//lib:jgit",
+    "//lib:jgit-ssh-jsch",
     "//lib:jsch",
     "//lib/commons:compress",
     "//lib/commons:lang",
diff --git a/java/com/google/gerrit/acceptance/EventRecorder.java b/java/com/google/gerrit/acceptance/EventRecorder.java
index 563c2ef..1618573 100644
--- a/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -70,9 +70,9 @@
               @Override
               public void onEvent(Event e) {
                 if (e instanceof ReviewerDeletedEvent) {
-                  recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+                  recordedEvents.put(ReviewerDeletedEvent.TYPE, e);
                 } else if (e instanceof ChangeDeletedEvent) {
-                  recordedEvents.put(ChangeDeletedEvent.TYPE, (ChangeDeletedEvent) e);
+                  recordedEvents.put(ChangeDeletedEvent.TYPE, e);
                 } else if (e instanceof RefEvent) {
                   RefEvent event = (RefEvent) e;
                   String key =
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 5376d23..cfe7964 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -73,6 +74,7 @@
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
       accountActivationValidationListeners;
+  private final DynamicSet<AccountActivationListener> accountActivationListeners;
   private final DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
   private final DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners;
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
@@ -101,6 +103,7 @@
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
+      DynamicSet<AccountActivationListener> accountActivationListeners,
       DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners,
       DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
@@ -126,6 +129,7 @@
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
+    this.accountActivationListeners = accountActivationListeners;
     this.onSubmitValidationListeners = onSubmitValidationListeners;
     this.workInProgressStateChangedListeners = workInProgressStateChangedListeners;
     this.capabilityDefinitions = capabilityDefinitions;
@@ -229,6 +233,10 @@
       return add(accountActivationValidationListeners, accountActivationValidationListener);
     }
 
+    public Registration add(AccountActivationListener accountDeactivatedListener) {
+      return add(accountActivationListeners, accountDeactivatedListener);
+    }
+
     public Registration add(OnSubmitValidationListener onSubmitValidationListener) {
       return add(onSubmitValidationListeners, onSubmitValidationListener);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 0ef6ad5..2d62608 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -483,7 +483,6 @@
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setBoolean("sendemail", null, "enable", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
-    cfg.setInt("cache", "projects", "checkFrequency", 0);
     cfg.setInt("plugins", null, "checkFrequency", 0);
 
     cfg.setInt("sshd", null, "threads", 1);
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 69c1790..fb03bc5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -24,7 +24,7 @@
   public String base;
   public Integer parent;
 
-  public NotifyHandling notify = NotifyHandling.NONE;
+  public NotifyHandling notify = NotifyHandling.ALL;
   public Map<RecipientType, NotifyInfo> notifyDetails;
 
   public boolean keepReviewers;
diff --git a/java/com/google/gerrit/extensions/events/AccountActivationListener.java b/java/com/google/gerrit/extensions/events/AccountActivationListener.java
new file mode 100644
index 0000000..b45533b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AccountActivationListener.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Notified whenever an account got activated or deactivated.
+ *
+ * <p>This listener is called only after an account got (de)activated and hence cannot cancel the
+ * (de)activation. See {@link
+ * com.google.gerrit.server.validators.AccountActivationValidationListener} for a listener that can
+ * cancel a (de)activation.
+ */
+@ExtensionPoint
+public interface AccountActivationListener {
+  /**
+   * Invoked after an account got activated
+   *
+   * @param id of the account
+   */
+  default void onAccountActivated(int id) {}
+
+  /**
+   * Invoked after an account got deactivated
+   *
+   * @param id of the account
+   */
+  default void onAccountDeactivated(int id) {}
+}
diff --git a/java/com/google/gerrit/httpd/AllRequestFilter.java b/java/com/google/gerrit/httpd/AllRequestFilter.java
index 9d171d5..1c3cafe 100644
--- a/java/com/google/gerrit/httpd/AllRequestFilter.java
+++ b/java/com/google/gerrit/httpd/AllRequestFilter.java
@@ -18,6 +18,8 @@
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.StopPluginListener;
 import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
 import com.google.inject.Singleton;
 import com.google.inject.internal.UniqueAnnotations;
 import com.google.inject.servlet.ServletModule;
@@ -32,11 +34,15 @@
 
 /** Filters all HTTP requests passing through the server. */
 public abstract class AllRequestFilter implements Filter {
-  public static ServletModule module() {
+  public static Module module() {
     return new ServletModule() {
       @Override
       protected void configureServlets() {
         DynamicSet.setOf(binder(), AllRequestFilter.class);
+        DynamicSet.bind(binder(), AllRequestFilter.class)
+            .to(AllowRenderInFrameFilter.class)
+            .in(Scopes.SINGLETON);
+
         filter("/*").through(FilterProxy.class);
 
         bind(StopPluginListener.class)
diff --git a/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java b/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java
new file mode 100644
index 0000000..b414aad
--- /dev/null
+++ b/java/com/google/gerrit/httpd/AllowRenderInFrameFilter.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+
+public class AllowRenderInFrameFilter extends AllRequestFilter {
+  static final String X_FRAME_OPTIONS_HEADER_NAME = "X-Frame-Options";
+
+  public static enum XFrameOption {
+    ALLOW,
+    SAMEORIGIN;
+  }
+
+  private final String xframeOptionString;
+  private final boolean skipXFrameOption;
+
+  @Inject
+  public AllowRenderInFrameFilter(@GerritServerConfig Config cfg) {
+    XFrameOption xframeOption =
+        cfg.getEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+    boolean canLoadInIFrame = cfg.getBoolean("gerrit", "canLoadInIFrame", false);
+    xframeOptionString = canLoadInIFrame ? xframeOption.name() : "DENY";
+
+    skipXFrameOption = xframeOption.equals(XFrameOption.ALLOW) && canLoadInIFrame;
+  }
+
+  @Override
+  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+      throws IOException, ServletException {
+    if (skipXFrameOption) {
+      chain.doFilter(request, response);
+    } else {
+      HttpServletResponse httpResponse = (HttpServletResponse) response;
+      httpResponse.addHeader(X_FRAME_OPTIONS_HEADER_NAME, xframeOptionString);
+      chain.doFilter(request, httpResponse);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 7f878aa..5c4830c 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -16,6 +16,7 @@
 
 import static java.util.concurrent.TimeUnit.HOURS;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -27,6 +28,7 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
@@ -40,7 +42,7 @@
 
 @RequestScoped
 public abstract class CacheBasedWebSession implements WebSession {
-  private static final String ACCOUNT_COOKIE = "GerritAccount";
+  @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
   protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
   private final HttpServletRequest request;
@@ -50,6 +52,7 @@
   private final Provider<AnonymousUser> anonymousProvider;
   private final IdentifiedUser.RequestFactory identified;
   private final EnumSet<AccessPath> okPaths = EnumSet.of(AccessPath.UNKNOWN);
+  private final AccountCache byIdCache;
   private Cookie outCookie;
 
   private Key key;
@@ -62,13 +65,15 @@
       WebSessionManager manager,
       AuthConfig authConfig,
       Provider<AnonymousUser> anonymousProvider,
-      IdentifiedUser.RequestFactory identified) {
+      IdentifiedUser.RequestFactory identified,
+      AccountCache byIdCache) {
     this.request = request;
     this.response = response;
     this.manager = manager;
     this.authConfig = authConfig;
     this.anonymousProvider = anonymousProvider;
     this.identified = identified;
+    this.byIdCache = byIdCache;
 
     if (request.getRequestURI() == null || !GitSmartHttpTools.isGitClient(request)) {
       String cookie = readCookie(request);
@@ -85,6 +90,10 @@
           authFromQueryParameter(token);
         }
       }
+      if (val != null && !checkAccountStatus(val.getAccountId())) {
+        val = null;
+        okPaths.clear();
+      }
       if (val != null && val.needsCookieRefresh()) {
         // Session is more than half old; update cache entry with new expiration date.
         val = manager.createVal(key, val);
@@ -177,6 +186,11 @@
       manager.destroy(key);
     }
 
+    if (!checkAccountStatus(id)) {
+      val = null;
+      return;
+    }
+
     key = manager.createKey(id);
     val = manager.createVal(key, id, rememberMe, identity, null, null);
     saveCookie();
@@ -207,6 +221,10 @@
     return val != null ? val.getSessionId() : null;
   }
 
+  private boolean checkAccountStatus(Account.Id id) {
+    return byIdCache.get(id).filter(as -> as.account().isActive()).isPresent();
+  }
+
   private void saveCookie() {
     if (response == null) {
       return;
diff --git a/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java b/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
index caced27..830d8d6 100644
--- a/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.httpd.WebSessionManager.Val;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser.RequestFactory;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
@@ -59,8 +60,15 @@
       @Named(WebSessionManager.CACHE_NAME) Cache<String, Val> cache,
       AuthConfig authConfig,
       Provider<AnonymousUser> anonymousProvider,
-      RequestFactory identified) {
+      RequestFactory identified,
+      AccountCache byIdCache) {
     super(
-        request, response, managerFactory.create(cache), authConfig, anonymousProvider, identified);
+        request,
+        response,
+        managerFactory.create(cache),
+        authConfig,
+        anonymousProvider,
+        identified,
+        byIdCache);
   }
 }
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 05992d4..0c3b7b0 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -246,7 +246,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private Injector createCfgInjector() {
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 41d2f83..cddaea4 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -153,8 +153,7 @@
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
     } catch (AuthException e) {
-      logger.atFine().withCause(e).log(
-          "Can't inline account-related data because user is unauthenticated");
+      logger.atFine().log("Can't inline account-related data because user is unauthenticated");
       // Don't render data
     }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 89ebdc1..f743578 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -601,6 +601,7 @@
         }
       } catch (MalformedJsonException | JsonParseException e) {
         cause = Optional.of(e);
+        logger.atFine().withCause(e).log("REST call failed on JSON parsing");
         responseBytes =
             replyError(
                 req, res, statusCode = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 0aa374b..3aa9de0 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -176,6 +177,33 @@
     return true;
   }
 
+  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+    if (skipFields.contains(f.getName())) {
+      return null;
+    }
+
+    Object v;
+    try {
+      v = f.get(obj);
+    } catch (StorageException e) {
+      // StorageException is thrown when the object is not found. On this case,
+      // it is pointless to make further attempts for each field, so propagate
+      // the exception to return an empty list.
+      logger.atSevere().withCause(e).log("error getting field %s of %s", f.getName(), obj);
+      throw e;
+    } catch (RuntimeException e) {
+      logger.atSevere().withCause(e).log("error getting field %s of %s", f.getName(), obj);
+      return null;
+    }
+    if (v == null) {
+      return null;
+    } else if (f.isRepeatable()) {
+      return new Values<>(f, (Iterable<?>) v);
+    } else {
+      return new Values<>(f, Collections.singleton(v));
+    }
+  }
+
   /**
    * Build all fields in the schema from an input object.
    *
@@ -186,31 +214,14 @@
    * @return all non-null field values from the object.
    */
   public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
-    return fields.values().stream()
-        .map(
-            f -> {
-              if (skipFields.contains(f.getName())) {
-                return null;
-              }
-
-              Object v;
-              try {
-                v = f.get(obj);
-              } catch (RuntimeException e) {
-                logger.atSevere().withCause(e).log(
-                    "error getting field %s of %s", f.getName(), obj);
-                return null;
-              }
-              if (v == null) {
-                return null;
-              } else if (f.isRepeatable()) {
-                return new Values<>(f, (Iterable<?>) v);
-              } else {
-                return new Values<>(f, Collections.singleton(v));
-              }
-            })
-        .filter(Objects::nonNull)
-        .collect(toImmutableList());
+    try {
+      return fields.values().stream()
+          .map(f -> fieldValues(obj, f, skipFields))
+          .filter(Objects::nonNull)
+          .collect(toImmutableList());
+    } catch (StorageException e) {
+      return ImmutableList.of();
+    }
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/SiteIndexer.java b/java/com/google/gerrit/index/SiteIndexer.java
index ecfc7bd..32b4b21 100644
--- a/java/com/google/gerrit/index/SiteIndexer.java
+++ b/java/com/google/gerrit/index/SiteIndexer.java
@@ -83,7 +83,7 @@
   }
 
   protected PrintWriter newPrintWriter(OutputStream out) {
-    return new PrintWriter(new OutputStreamWriter(out, UTF_8));
+    return new PrintWriter(new OutputStreamWriter(out, UTF_8), true);
   }
 
   private static class ErrorListener implements Runnable {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 034e042e..57bec71 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -380,7 +380,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private String myVersion() {
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index 3593d8a..4e62a0f 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.PluginData;
+import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.pgm.init.BaseInit;
 import com.google.gerrit.pgm.init.Browser;
@@ -31,6 +32,9 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.server.ioutil.HostPlatform;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.gerrit.server.util.ReplicaUtil;
@@ -89,7 +93,7 @@
 
   @Inject Browser browser;
 
-  private boolean projectsIndexExists;
+  private GerritIndexStatus indexStatus;
 
   public Init() {
     super(new WarDistribution(), null);
@@ -103,7 +107,7 @@
 
   @Override
   protected boolean beforeInit(SiteInit init) throws Exception {
-    projectsIndexExists = new GerritIndexStatus(init.site).exists(ProjectSchemaDefinitions.NAME);
+    indexStatus = new GerritIndexStatus(init.site);
     ErrorLogFile.errorOnlyConsole();
 
     if (!skipPlugins) {
@@ -132,6 +136,12 @@
 
   @Override
   protected void afterInit(SiteRun run) throws Exception {
+    List<SchemaDefinitions<?>> schemaDefs =
+        ImmutableList.of(
+            AccountSchemaDefinitions.INSTANCE,
+            ChangeSchemaDefinitions.INSTANCE,
+            GroupSchemaDefinitions.INSTANCE,
+            ProjectSchemaDefinitions.INSTANCE);
     List<Module> modules = new ArrayList<>();
     modules.add(
         new AbstractModule() {
@@ -146,8 +156,12 @@
         });
     modules.add(new GerritServerConfigModule());
     Guice.createInjector(modules).injectMembers(this);
-    if (!ReplicaUtil.isReplica(run.flags.cfg) && !projectsIndexExists) {
-      reindexProjects();
+    if (!ReplicaUtil.isReplica(run.flags.cfg)) {
+      for (SchemaDefinitions<?> schemaDef : schemaDefs) {
+        if (!indexStatus.exists(schemaDef.getName())) {
+          reindex(schemaDef);
+        }
+      }
     }
     start(run);
   }
@@ -260,8 +274,7 @@
     }
   }
 
-  private void reindexProjects() throws Exception {
-    // Reindex all projects, so that we bootstrap the project index for new installations
+  private void reindex(SchemaDefinitions<?> schemaDef) throws Exception {
     List<String> reindexArgs =
         ImmutableList.of(
             "--site-path",
@@ -269,8 +282,9 @@
             "--threads",
             Integer.toString(1),
             "--index",
-            ProjectSchemaDefinitions.NAME);
-    getConsoleUI().message("Init complete, reindexing projects with:");
+            schemaDef.getName());
+    getConsoleUI()
+        .message(String.format("Init complete, reindexing %s with:", schemaDef.getName()));
     getConsoleUI().message(" reindex " + reindexArgs.stream().collect(joining(" ")));
     Reindex reindexPgm = new Reindex();
     reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 2e526bb..966801f 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -202,6 +202,9 @@
     if (result.success()) {
       index.markReady(true);
     }
+    System.out.format(
+        "Index %s in version %d is %sready\n",
+        def.getName(), index.getSchema().getVersion(), result.success() ? "" : "NOT ");
     return result.success();
   }
 }
diff --git a/java/com/google/gerrit/server/CacheRefreshExecutor.java b/java/com/google/gerrit/server/CacheRefreshExecutor.java
new file mode 100644
index 0000000..1a377c3
--- /dev/null
+++ b/java/com/google/gerrit/server/CacheRefreshExecutor.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on the global {@link java.util.concurrent.ThreadPoolExecutor} used to refresh outdated
+ * values in caches.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CacheRefreshExecutor {}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index dd48b93..32edadb 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -35,34 +35,38 @@
 @Singleton
 public class ChangeMessagesUtil {
   public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
+  public static final String AUTOGENERATED_BY_GERRIT_TAG_PREFIX =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:";
 
-  public static final String TAG_ABANDON = AUTOGENERATED_TAG_PREFIX + "gerrit:abandon";
+  public static final String TAG_ABANDON = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "abandon";
   public static final String TAG_CHERRY_PICK_CHANGE =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:cherryPickChange";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "cherryPickChange";
   public static final String TAG_DELETE_ASSIGNEE =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteAssignee";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteAssignee";
   public static final String TAG_DELETE_REVIEWER =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteReviewer";
-  public static final String TAG_DELETE_VOTE = AUTOGENERATED_TAG_PREFIX + "gerrit:deleteVote";
-  public static final String TAG_MERGED = AUTOGENERATED_TAG_PREFIX + "gerrit:merged";
-  public static final String TAG_MOVE = AUTOGENERATED_TAG_PREFIX + "gerrit:move";
-  public static final String TAG_RESTORE = AUTOGENERATED_TAG_PREFIX + "gerrit:restore";
-  public static final String TAG_REVERT = AUTOGENERATED_TAG_PREFIX + "gerrit:revert";
-  public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_TAG_PREFIX + "gerrit:setAssignee";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteReviewer";
+  public static final String TAG_DELETE_VOTE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteVote";
+  public static final String TAG_MERGED = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "merged";
+  public static final String TAG_MOVE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "move";
+  public static final String TAG_RESTORE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "restore";
+  public static final String TAG_REVERT = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "revert";
+  public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setAssignee";
   public static final String TAG_UPDATE_ATTENTION_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:updateAttentionSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "updateAttentionSet";
   public static final String TAG_SET_DESCRIPTION =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:setPsDescription";
-  public static final String TAG_SET_HASHTAGS = AUTOGENERATED_TAG_PREFIX + "gerrit:setHashtag";
-  public static final String TAG_SET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:setPrivate";
-  public static final String TAG_SET_READY = AUTOGENERATED_TAG_PREFIX + "gerrit:setReadyForReview";
-  public static final String TAG_SET_TOPIC = AUTOGENERATED_TAG_PREFIX + "gerrit:setTopic";
-  public static final String TAG_SET_WIP = AUTOGENERATED_TAG_PREFIX + "gerrit:setWorkInProgress";
-  public static final String TAG_UNSET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:unsetPrivate";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setPsDescription";
+  public static final String TAG_SET_HASHTAGS = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setHashtag";
+  public static final String TAG_SET_PRIVATE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setPrivate";
+  public static final String TAG_SET_READY =
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setReadyForReview";
+  public static final String TAG_SET_TOPIC = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setTopic";
+  public static final String TAG_SET_WIP = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setWorkInProgress";
+  public static final String TAG_UNSET_PRIVATE =
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "unsetPrivate";
   public static final String TAG_UPLOADED_PATCH_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:newPatchSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newPatchSet";
   public static final String TAG_UPLOADED_WIP_PATCH_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:newWipPatchSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newWipPatchSet";
 
   public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
     return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
@@ -122,6 +126,10 @@
     return tag != null && tag.startsWith(AUTOGENERATED_TAG_PREFIX);
   }
 
+  public static boolean isAutogeneratedByGerrit(@Nullable String tag) {
+    return tag != null && tag.startsWith(AUTOGENERATED_BY_GERRIT_TAG_PREFIX);
+  }
+
   public static ChangeMessageInfo createChangeMessageInfo(
       ChangeMessage message, AccountLoader accountLoader) {
     PatchSet.Id patchNum = message.getPatchSetId();
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 450cbe0..e8b44aa 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -272,7 +272,9 @@
   }
 
   private static boolean isAutoGenerated(ChangeMessage cm) {
-    return ChangeMessagesUtil.isAutogenerated(cm.getTag());
+    // Ignore Gerrit auto-generated messages, allowing to link against human change messages that
+    // have an auto-generated tag
+    return ChangeMessagesUtil.isAutogeneratedByGerrit(cm.getTag());
   }
 
   private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index df57629..745755b 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -31,6 +32,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -114,7 +116,16 @@
     PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
-      email.create(notify, changeNotes, ps, user, message, comments, null, labelDelta).sendAsync();
+      RepoView repoView;
+      try {
+        repoView = ctx.getRepoView();
+      } catch (IOException ex) {
+        throw new StorageException(
+            String.format("Repository %s not found", ctx.getProject().get()), ex);
+      }
+      email
+          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .sendAsync();
     }
     commentAdded.fire(
         changeNotes.getChange(),
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 345da81..7a5b1aa 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -18,6 +18,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -78,8 +79,9 @@
   private final boolean autoUpdateAccountActiveStatus;
   private final SetInactiveFlag setInactiveFlag;
 
+  @VisibleForTesting
   @Inject
-  AccountManager(
+  public AccountManager(
       Sequences sequences,
       @GerritServerConfig Config cfg,
       Accounts accounts,
@@ -239,13 +241,7 @@
 
     if (!Strings.isNullOrEmpty(who.getDisplayName())
         && !Objects.equals(user.getAccount().fullName(), who.getDisplayName())) {
-      if (realm.allowsEdit(AccountFieldName.FULL_NAME)) {
-        accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
-      } else {
-        logger.atWarning().log(
-            "Not changing already set display name '%s' to '%s'",
-            user.getAccount().fullName(), who.getDisplayName());
-      }
+      accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index 32ed694..4b68198 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -38,13 +39,16 @@
   private final PluginSetContext<AccountActivationValidationListener>
       accountActivationValidationListeners;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final PluginSetContext<AccountActivationListener> accountActivationListeners;
 
   @Inject
   SetInactiveFlag(
       PluginSetContext<AccountActivationValidationListener> accountActivationValidationListeners,
-      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+      @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      PluginSetContext<AccountActivationListener> accountActivationListeners) {
     this.accountActivationValidationListeners = accountActivationValidationListeners;
     this.accountsUpdateProvider = accountsUpdateProvider;
+    this.accountActivationListeners = accountActivationListeners;
   }
 
   public Response<?> deactivate(Account.Id accountId)
@@ -77,6 +81,12 @@
     if (alreadyInactive.get()) {
       throw new ResourceConflictException("account not active");
     }
+
+    // At this point the account got set inactive and no errors occurred
+
+    int id = accountId.get();
+    accountActivationListeners.runEach(l -> l.onAccountDeactivated(id));
+
     return Response.none();
   }
 
@@ -107,6 +117,16 @@
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
-    return alreadyActive.get() ? Response.ok() : Response.created();
+
+    Response<String> res;
+    if (alreadyActive.get()) {
+      res = Response.ok();
+    } else {
+      res = Response.created();
+
+      int id = accountId.get();
+      accountActivationListeners.runEach(l -> l.onAccountActivated(id));
+    }
+    return res;
   }
 }
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index c53ba83..1421f17 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -218,7 +218,7 @@
       values.put(name, m.get(name));
     }
 
-    String r = p.replace(values);
+    String r = p.replace(values).trim();
     return r.isEmpty() ? null : r;
   }
 
diff --git a/java/com/google/gerrit/server/cache/CacheBinding.java b/java/com/google/gerrit/server/cache/CacheBinding.java
index 9d90d073..99db64e 100644
--- a/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -29,6 +29,13 @@
   /** Set the time an element lives after last access before being expired. */
   CacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
 
+  /**
+   * Set the time that an element will be refreshed after. Elements older than this but younger than
+   * {@link #expireAfterWrite(Duration)} will still be returned, but on access a task is queued to
+   * refresh their value asynchronously.
+   */
+  CacheBinding<K, V> refreshAfterWrite(Duration duration);
+
   /** Populate the cache with items from the CacheLoader. */
   CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
diff --git a/java/com/google/gerrit/server/cache/CacheDef.java b/java/com/google/gerrit/server/cache/CacheDef.java
index d0c633e..31a453e 100644
--- a/java/com/google/gerrit/server/cache/CacheDef.java
+++ b/java/com/google/gerrit/server/cache/CacheDef.java
@@ -51,6 +51,9 @@
   Duration expireFromMemoryAfterAccess();
 
   @Nullable
+  Duration refreshAfterWrite();
+
+  @Nullable
   Weigher<K, V> weigher();
 
   @Nullable
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index fe4244c..2dd9e1f 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -38,6 +38,7 @@
   private long maximumWeight;
   private Duration expireAfterWrite;
   private Duration expireFromMemoryAfterAccess;
+  private Duration refreshAfterWrite;
   private Provider<CacheLoader<K, V>> loader;
   private Provider<Weigher<K, V>> weigher;
 
@@ -90,6 +91,13 @@
   }
 
   @Override
+  public CacheBinding<K, V> refreshAfterWrite(Duration duration) {
+    checkNotFrozen();
+    refreshAfterWrite = duration;
+    return this;
+  }
+
+  @Override
   public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> impl) {
     checkNotFrozen();
     loader = module.bindCacheLoader(this, impl);
@@ -151,6 +159,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return refreshAfterWrite;
+  }
+
+  @Override
   @Nullable
   public Weigher<K, V> weigher() {
     return weigher != null ? weigher.get() : null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 5d9ce60..aa62745 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -43,6 +43,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return source.refreshAfterWrite();
+  }
+
+  @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
     if (weigher == null) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 8f7e360..82615a4 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -237,6 +237,7 @@
         def.valueSerializer(),
         def.version(),
         maxSize,
-        def.expireAfterWrite());
+        def.expireAfterWrite(),
+        def.expireFromMemoryAfterAccess());
   }
 }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index ef4e44c..7a53600 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -23,6 +23,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.BloomFilter;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -40,6 +43,7 @@
 import java.sql.Statement;
 import java.sql.Timestamp;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.Calendar;
 import java.util.Map;
 import java.util.concurrent.ArrayBlockingQueue;
@@ -122,7 +126,12 @@
   @Override
   public V get(K key) throws ExecutionException {
     if (mem instanceof LoadingCache) {
-      return ((LoadingCache<K, ValueHolder<V>>) mem).get(key).value;
+      LoadingCache<K, ValueHolder<V>> asLoadingCache = (LoadingCache<K, ValueHolder<V>>) mem;
+      ValueHolder<V> valueHolder = asLoadingCache.get(key);
+      if (store.needsRefresh(valueHolder.created)) {
+        asLoadingCache.refresh(key);
+      }
+      return valueHolder.value;
     }
     throw new UnsupportedOperationException();
   }
@@ -139,8 +148,8 @@
                 }
               }
 
-              ValueHolder<V> h = new ValueHolder<>(valueLoader.call());
-              h.created = TimeUtil.nowMs();
+              ValueHolder<V> h =
+                  new ValueHolder<>(valueLoader.call(), Instant.ofEpochMilli(TimeUtil.nowMs()));
               executor.execute(() -> store.put(key, h));
               return h;
             })
@@ -149,8 +158,7 @@
 
   @Override
   public void put(K key, V val) {
-    final ValueHolder<V> h = new ValueHolder<>(val);
-    h.created = TimeUtil.nowMs();
+    final ValueHolder<V> h = new ValueHolder<>(val, Instant.ofEpochMilli(TimeUtil.nowMs()));
     mem.put(key, h);
     executor.execute(() -> store.put(key, h));
   }
@@ -217,11 +225,12 @@
 
   static class ValueHolder<V> {
     final V value;
-    long created;
+    final Instant created;
     volatile boolean clean;
 
-    ValueHolder(V value) {
+    ValueHolder(V value, Instant created) {
       this.value = value;
+      this.created = created;
     }
   }
 
@@ -248,12 +257,34 @@
           }
         }
 
-        final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
-        h.created = TimeUtil.nowMs();
+        final ValueHolder<V> h =
+            new ValueHolder<>(loader.load(key), Instant.ofEpochMilli(TimeUtil.nowMs()));
         executor.execute(() -> store.put(key, h));
         return h;
       }
     }
+
+    @Override
+    public ListenableFuture<ValueHolder<V>> reload(K key, ValueHolder<V> oldValue)
+        throws Exception {
+      ListenableFuture<V> reloadedValue = loader.reload(key, oldValue.value);
+      Futures.addCallback(
+          reloadedValue,
+          new FutureCallback<V>() {
+            @Override
+            public void onSuccess(V result) {
+              store.put(key, new ValueHolder<>(result, TimeUtil.now()));
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+              logger.atWarning().withCause(t).log("Unable to reload cache value");
+            }
+          },
+          executor);
+
+      return Futures.transform(reloadedValue, v -> new ValueHolder<>(v, TimeUtil.now()), executor);
+    }
   }
 
   static class SqlStore<K, V> {
@@ -263,6 +294,7 @@
     private final int version;
     private final long maxSize;
     @Nullable private final Duration expireAfterWrite;
+    @Nullable private final Duration refreshAfterWrite;
     private final BlockingQueue<SqlHandle> handles;
     private final AtomicLong hitCount = new AtomicLong();
     private final AtomicLong missCount = new AtomicLong();
@@ -276,13 +308,15 @@
         CacheSerializer<V> valueSerializer,
         int version,
         long maxSize,
-        @Nullable Duration expireAfterWrite) {
+        @Nullable Duration expireAfterWrite,
+        @Nullable Duration refreshAfterWrite) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
       this.version = version;
       this.maxSize = maxSize;
       this.expireAfterWrite = expireAfterWrite;
+      this.refreshAfterWrite = refreshAfterWrite;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -394,14 +428,14 @@
           }
 
           Timestamp created = r.getTimestamp(2);
-          if (expired(created)) {
+          if (expired(created.toInstant())) {
             invalidate(key);
             missCount.incrementAndGet();
             return null;
           }
 
           V val = valueSerializer.deserialize(r.getBytes(1));
-          ValueHolder<V> h = new ValueHolder<>(val);
+          ValueHolder<V> h = new ValueHolder<>(val, created.toInstant());
           h.clean = true;
           hitCount.incrementAndGet();
           touch(c, key);
@@ -429,14 +463,22 @@
       return false;
     }
 
-    private boolean expired(Timestamp created) {
+    private boolean expired(Instant created) {
       if (expireAfterWrite == null) {
         return false;
       }
-      Duration age = Duration.between(created.toInstant(), TimeUtil.now());
+      Duration age = Duration.between(created, TimeUtil.now());
       return age.compareTo(expireAfterWrite) > 0;
     }
 
+    private boolean needsRefresh(Instant created) {
+      if (refreshAfterWrite == null) {
+        return false;
+      }
+      Duration age = Duration.between(created, TimeUtil.now());
+      return age.compareTo(refreshAfterWrite) > 0;
+    }
+
     private void touch(SqlHandle c, K key) throws IOException, SQLException {
       if (c.touch == null) {
         c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
@@ -474,7 +516,7 @@
           keyType.set(c.put, 1, key);
           c.put.setBytes(2, valueSerializer.serialize(holder.value));
           c.put.setInt(3, version);
-          c.put.setTimestamp(4, new Timestamp(holder.created));
+          c.put.setTimestamp(4, Timestamp.from(holder.created));
           c.put.setTimestamp(5, TimeUtil.nowTs());
           c.put.executeUpdate();
           holder.clean = true;
@@ -560,7 +602,7 @@
             while (maxSize < used && r.next()) {
               K key = keyType.get(r, 1);
               Timestamp created = r.getTimestamp(3);
-              if (mem.getIfPresent(key) != null && !expired(created)) {
+              if (mem.getIfPresent(key) != null && !expired(created.toInstant())) {
                 touch(c, key);
               } else {
                 invalidate(c, key);
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index 9906b3d..23caca7 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -105,6 +105,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.refreshAfterWrite(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
@@ -141,6 +156,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.expireAfterAccess(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index eb6e8d7..9e228d9 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -42,6 +43,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final ChangeAbandoned changeAbandoned;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final String msgTxt;
   private final AccountState accountState;
@@ -61,12 +63,14 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
+      MessageIdGenerator messageIdGenerator,
       @Assisted @Nullable AccountState accountState,
       @Assisted @Nullable String msgTxt) {
     this.abandonedSenderFactory = abandonedSenderFactory;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.changeAbandoned = changeAbandoned;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.accountState = accountState;
     this.msgTxt = Strings.nullToEmpty(msgTxt);
@@ -116,6 +120,7 @@
       }
       cm.setChangeMessage(message.getMessage(), ctx.getWhen());
       cm.setNotify(notify);
+      cm.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
       cm.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index 2778bdd..ae3851e 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -37,13 +38,16 @@
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
   private final ExecutorService sendEmailsExecutor;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   AddReviewersEmail(
       AddReviewerSender.Factory addReviewerSenderFactory,
-      @SendEmailExecutor ExecutorService sendEmailsExecutor) {
+      @SendEmailExecutor ExecutorService sendEmailsExecutor,
+      MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
     this.sendEmailsExecutor = sendEmailsExecutor;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public void emailReviewersAsync(
@@ -86,6 +90,9 @@
                 cm.addReviewersByEmail(immutableAddedByEmail);
                 cm.addExtraCC(immutableToCopy);
                 cm.addExtraCCByEmail(immutableCopiedByEmail);
+                cm.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(
+                        change.getProject(), change.currentPatchSetId()));
                 cm.send();
               } catch (Exception err) {
                 logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index bbb94ea..467c4a2 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -108,6 +109,7 @@
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final ReviewerAdder reviewerAdder;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -156,6 +158,7 @@
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       ReviewerAdder reviewerAdder,
+      MessageIdGenerator messageIdGenerator,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -171,6 +174,7 @@
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.reviewerAdder = reviewerAdder;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -475,6 +479,8 @@
                 cm.addExtraCC(reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
                 cm.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
+                cm.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
                 cm.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 3bc9324..e52375f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -36,6 +37,8 @@
   }
 
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final MessageIdGenerator messageIdGenerator;
+
   private final Address reviewer;
 
   private ChangeMessage changeMessage;
@@ -43,8 +46,11 @@
 
   @Inject
   DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory, @Assisted Address reviewer) {
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted Address reviewer) {
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewer;
   }
 
@@ -79,6 +85,8 @@
       cm.addReviewersByEmail(Collections.singleton(reviewer));
       cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
       cm.setNotify(notify);
+      cm.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
       cm.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 099334d..5a9fb99 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -45,6 +46,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -69,6 +71,7 @@
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final AccountState reviewer;
   private final DeleteReviewerInput input;
@@ -90,6 +93,7 @@
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator,
       @Assisted AccountState reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
@@ -101,6 +105,7 @@
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -177,7 +182,7 @@
     }
     try {
       if (notify.shouldNotify()) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage, notify);
+        emailReviewers(ctx.getProject(), currChange, changeMessage, notify, ctx.getRepoView());
       }
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
@@ -211,7 +216,8 @@
       Project.NameKey projectName,
       Change change,
       ChangeMessage changeMessage,
-      NotifyResolver.Result notify)
+      NotifyResolver.Result notify,
+      RepoView repoView)
       throws EmailException {
     Account.Id userId = user.get().getAccountId();
     if (userId.equals(reviewer.account().id())) {
@@ -223,6 +229,7 @@
     cm.addReviewers(Collections.singleton(reviewer.account().id()));
     cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
     cm.setNotify(notify);
+    cm.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
     cm.send();
   }
 }
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f7e45e7..f1eff6f 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -25,8 +25,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -65,13 +67,15 @@
         ChangeMessage message,
         List<Comment> comments,
         String patchSetComment,
-        List<LabelVote> labels);
+        List<LabelVote> labels,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommentSender.Factory commentSenderFactory;
   private final ThreadLocalRequestContext requestContext;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final NotifyResolver.Result notify;
   private final ChangeNotes notes;
@@ -81,6 +85,7 @@
   private final List<Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
+  private final RepoView repoView;
 
   @Inject
   EmailReviewComments(
@@ -88,6 +93,7 @@
       PatchSetInfoFactory patchSetInfoFactory,
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       @Assisted NotifyResolver.Result notify,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
@@ -95,11 +101,13 @@
       @Assisted ChangeMessage message,
       @Assisted List<Comment> comments,
       @Nullable @Assisted String patchSetComment,
-      @Assisted List<LabelVote> labels) {
+      @Assisted List<LabelVote> labels,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
     this.requestContext = requestContext;
+    this.messageIdGenerator = messageIdGenerator;
     this.notify = notify;
     this.notes = notes;
     this.patchSet = patchSet;
@@ -108,6 +116,7 @@
     this.comments = COMMENT_ORDER.sortedCopy(comments);
     this.patchSetComment = patchSetComment;
     this.labels = labels;
+    this.repoView = repoView;
   }
 
   public void sendAsync() {
@@ -127,6 +136,7 @@
       cm.setPatchSetComment(patchSetComment);
       cm.setLabels(labels);
       cm.setNotify(notify);
+      cm.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, patchSet.id()));
       cm.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 988d178..d4d74a3 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -79,6 +80,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
+  private final MessageIdGenerator messageIdGenerator;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -120,6 +122,7 @@
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -133,6 +136,7 @@
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -291,6 +295,7 @@
         cm.addReviewers(oldReviewers.byState(REVIEWER));
         cm.addExtraCC(oldReviewers.byState(CC));
         cm.setNotify(notify);
+        cm.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 9848150..74536aa 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -50,6 +51,7 @@
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private Change change;
   private IdentifiedUser oldAssignee;
@@ -62,6 +64,7 @@
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory userFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser newAssignee) {
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
@@ -69,6 +72,7 @@
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.newAssignee = requireNonNull(newAssignee, "assignee");
   }
 
@@ -122,6 +126,8 @@
           setAssigneeSenderFactory.create(
               change.getProject(), change.getId(), newAssignee.getAccountId());
       cm.setFrom(user.get().getAccountId());
+      cm.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
       cm.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 283cff8..f0ebb80 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -30,8 +31,10 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -131,6 +134,13 @@
         || !sendEmail) {
       return;
     }
+    RepoView repoView;
+    try {
+      repoView = ctx.getRepoView();
+    } catch (IOException ex) {
+      throw new StorageException(
+          String.format("Repository %s not found", ctx.getProject().get()), ex);
+    }
     email
         .create(
             notify,
@@ -140,7 +150,8 @@
             cmsg,
             ImmutableList.of(),
             cmsg.getMessage(),
-            ImmutableList.of())
+            ImmutableList.of(),
+            repoView)
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index e447f2b..cf592bf 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.config.ExternalIncludedIn;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
@@ -343,6 +344,7 @@
     DynamicSet.setOf(binder(), PostReceiveHook.class);
     DynamicSet.setOf(binder(), PreUploadHook.class);
     DynamicSet.setOf(binder(), PostUploadHook.class);
+    DynamicSet.setOf(binder(), AccountActivationListener.class);
     DynamicSet.setOf(binder(), AccountIndexedListener.class);
     DynamicSet.setOf(binder(), ChangeIndexedListener.class);
     DynamicSet.setOf(binder(), GroupIndexedListener.class);
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index e7f4540..ea45b12 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.AbstractModule;
@@ -24,7 +28,7 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Module providing the {@link ReceiveCommitsExecutor}.
+ * Module providing different executors.
  *
  * <p>This module is intended to be installed at the top level when creating a {@code sysInjector}
  * in {@code Daemon} or similar, not nested in another module. This ensures the module can be
@@ -37,7 +41,7 @@
   @Provides
   @Singleton
   @ReceiveCommitsExecutor
-  public ExecutorService createReceiveCommitsExecutor(
+  public ExecutorService provideReceiveCommitsExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize =
         config.getInt(
@@ -48,11 +52,11 @@
   @Provides
   @Singleton
   @SendEmailExecutor
-  public ExecutorService createSendEmailExecutor(
+  public ExecutorService provideSendEmailExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "SendEmail", true);
   }
@@ -60,11 +64,24 @@
   @Provides
   @Singleton
   @FanOutExecutor
-  public ExecutorService createFanOutExecutor(@GerritServerConfig Config config, WorkQueue queues) {
+  public ExecutorService provideFanOutExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("execution", null, "fanOutThreadPoolSize", 25);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "FanOut");
   }
+
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService provideCacheRefreshExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize = config.getInt("cache", null, "refreshThreadPoolSize", 2);
+    if (poolSize == 0) {
+      return newDirectExecutorService();
+    }
+    return MoreExecutors.listeningDecorator(queues.createQueue(poolSize, "CacheRefresh"));
+  }
 }
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 8fc0d9e..d3f90e5 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -52,6 +52,11 @@
     return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=comments");
   }
 
+  /** Returns the URL for viewing the findings tab view of a change. */
+  default Optional<String> getFindingsTabView(Change change) {
+    return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=findings");
+  }
+
   /** Returns the URL for viewing a file in a given patch set of a change. */
   default Optional<String> getPatchFileView(Change change, int patchsetId, String filename) {
     return getChangeViewUrl(change.getProject(), change.getId())
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index df53133..329530c 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -86,6 +87,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   CommitUtil(
@@ -98,7 +100,8 @@
       RevertedSender.Factory revertedSenderFactory,
       ChangeMessagesUtil cmUtil,
       ChangeReverted changeReverted,
-      BatchUpdate.Factory updateFactory) {
+      BatchUpdate.Factory updateFactory,
+      MessageIdGenerator messageIdGenerator) {
     this.repoManager = repoManager;
     this.serverIdent = serverIdent;
     this.seq = seq;
@@ -109,6 +112,7 @@
     this.cmUtil = cmUtil;
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
@@ -308,6 +312,8 @@
         RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setNotify(ctx.getNotify(change.getId()));
+        cm.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index b272cba..89d6bd3 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -70,6 +71,7 @@
   private final PatchSetUtil psUtil;
   private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final PatchSet.Id psId;
   private final SubmissionId submissionId;
@@ -90,6 +92,7 @@
       PatchSetUtil psUtil,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
       @Assisted SubmissionId submissionId,
@@ -101,6 +104,7 @@
     this.psUtil = psUtil;
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
+    this.messageIdGenerator = messageIdGenerator;
     this.requestScopePropagator = requestScopePropagator;
     this.submissionId = submissionId;
     this.psId = psId;
@@ -189,6 +193,8 @@
                           mergedSenderFactory.create(ctx.getProject(), psId.changeId());
                       cm.setFrom(ctx.getAccountId());
                       cm.setPatchSet(patchSet, info);
+                      cm.setMessageId(
+                          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
                       cm.send();
                     } catch (Exception e) {
                       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index f2a0ff1..4b08040 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -606,9 +606,12 @@
     @Override
     public void run() {
       if (running.compareAndSet(false, true)) {
+        String oldThreadName = Thread.currentThread().getName();
         try {
+          Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
           task.run();
         } finally {
+          Thread.currentThread().setName(oldThreadName);
           if (isPeriodic()) {
             running.set(false);
           } else {
@@ -681,5 +684,10 @@
     public boolean hasCustomizedPrint() {
       return runnable.hasCustomizedPrint();
     }
+
+    @Override
+    public String toString() {
+      return runnable.toString();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 0baecf5..f5c69e0 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -61,6 +61,7 @@
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -128,6 +129,8 @@
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerAdder reviewerAdder;
+  private final Change change;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final ProjectState projectState;
   private final BranchNameKey dest;
@@ -140,7 +143,6 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
-  private final Change change;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -172,6 +174,7 @@
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       Change change,
+      MessageIdGenerator messageIdGenerator,
       @Assisted ProjectState projectState,
       @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
@@ -197,6 +200,8 @@
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
     this.reviewerAdder = reviewerAdder;
+    this.change = change;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.projectState = projectState;
     this.dest = dest;
@@ -210,7 +215,6 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
-    this.change = change;
   }
 
   @Override
@@ -533,6 +537,7 @@
                     oldRecipients.getCcOnly().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
                 .collect(toImmutableSet()));
+        cm.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
         // TODO(dborowitz): Support byEmail
         cm.send();
       } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 7535f51..923ba68 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -45,6 +45,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.validators.ValidationMessage.Type;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -230,7 +233,17 @@
     List<CommitValidationMessage> messages = new ArrayList<>();
     try {
       for (CommitValidationListener commitValidator : validators) {
-        messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Running CommitValidationListener",
+                Metadata.builder()
+                    .className(commitValidator.getClass().getSimpleName())
+                    .projectName(receiveEvent.getProjectNameKey().get())
+                    .branchName(receiveEvent.getBranchNameKey().branch())
+                    .commit(receiveEvent.commit.name())
+                    .build())) {
+          messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+        }
       }
     } catch (CommitValidationException e) {
       logger.atFine().withCause(e).log(
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 2a8a5ba..e9349c4 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.base.Stopwatch;
-import com.google.common.collect.ComparisonChain;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -43,10 +42,9 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
-import java.util.SortedSet;
-import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -62,6 +60,7 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
   private final ChangeData.Factory changeDataFactory;
   private final GitRepositoryManager repoManager;
@@ -86,22 +85,27 @@
     this.projectCache = projectCache;
   }
 
-  private static class ProjectHolder implements Comparable<ProjectHolder> {
-    final Project.NameKey name;
-    private final long size;
+  private static class ProjectSlice {
+    private final Project.NameKey name;
+    private final int slice;
+    private final int slices;
 
-    ProjectHolder(Project.NameKey name, long size) {
+    ProjectSlice(Project.NameKey name, int slice, int slices) {
       this.name = name;
-      this.size = size;
+      this.slice = slice;
+      this.slices = slices;
     }
 
-    @Override
-    public int compareTo(ProjectHolder other) {
-      // Sort projects based on size first to maximize utilization of threads early on.
-      return ComparisonChain.start()
-          .compare(other.size, size)
-          .compare(other.name.get(), name.get())
-          .result();
+    public Project.NameKey getName() {
+      return name;
+    }
+
+    public int getSlice() {
+      return slice;
+    }
+
+    public int getSlices() {
+      return slices;
     }
   }
 
@@ -109,19 +113,39 @@
   public Result indexAll(ChangeIndex index) {
     ProgressMonitor pm = new TextProgressMonitor();
     pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-    SortedSet<ProjectHolder> projects = new TreeSet<>();
+    List<ProjectSlice> projectSlices = new ArrayList<>();
     int changeCount = 0;
     Stopwatch sw = Stopwatch.createStarted();
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
+        // The simplest approach to distribute indexing would be to let each thread grab a project
+        // and index it fully. But if a site has one big project and 100s of small projects, then
+        // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
+        // projects have been reindexed, and only the thread that reindexes the big project is
+        // still working. The other threads would idle. Reindexing the big project on a single
+        // thread becomes the critical path. Bringing in more CPUs would not speed up things.
+        //
+        // To avoid such situations, we split big repos into smaller parts and let
+        // the thread pool index these smaller parts. This splitting introduces an overhead in the
+        // workload setup and there might be additional slow-downs from multiple threads
+        // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
+        // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
+        // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
+        // in 2020.
         int size = estimateSize(repo);
         changeCount += size;
-        projects.add(new ProjectHolder(name, size));
+        int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
+        if (slices > 1) {
+          verboseWriter.println("Submitting " + name + " for indexing in " + slices + " slices");
+        }
+        for (int slice = 0; slice < slices; slice++) {
+          projectSlices.add(new ProjectSlice(name, slice, slices));
+        }
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Error collecting project %s", name);
         projectsFailed++;
-        if (projectsFailed > projects.size() / 2) {
+        if (projectsFailed > projectCache.all().size() / 2) {
           logger.atSevere().log("Over 50%% of the projects could not be collected: aborted");
           return Result.create(sw, false, 0, 0);
         }
@@ -130,7 +154,15 @@
     }
     pm.endTask();
     setTotalWork(changeCount);
-    return indexAll(index, projects);
+
+    // projectSlices are currently grouped by projects. First all slices for project1, followed
+    // by all slices for project2, and so on. As workers pick tasks sequentially, multiple threads
+    // would typically work concurrently on different slices of the same project. While this is not
+    // a big issue, shuffling the list beforehand helps with ungrouping the project slices, so
+    // different slices are less likely to be worked on concurrently.
+    // This shuffling gave a 6% runtime reduction for Wikimedia's Gerrit in 2020.
+    Collections.shuffle(projectSlices);
+    return indexAll(index, projectSlices);
   }
 
   private int estimateSize(Repository repo) throws IOException {
@@ -146,10 +178,10 @@
     return Ints.saturatedCast(size);
   }
 
-  private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
+  private SiteIndexer.Result indexAll(ChangeIndex index, List<ProjectSlice> projectSlices) {
     Stopwatch sw = Stopwatch.createStarted();
     MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
-    Task projTask = mpm.beginSubTask("projects", projects.size());
+    Task projTask = mpm.beginSubTask("project-slices", projectSlices.size());
     checkState(totalWork >= 0);
     Task doneTask = mpm.beginSubTask(null, totalWork);
     Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
@@ -157,12 +189,21 @@
     List<ListenableFuture<?>> futures = new ArrayList<>();
     AtomicBoolean ok = new AtomicBoolean(true);
 
-    for (ProjectHolder project : projects) {
+    for (ProjectSlice projectSlice : projectSlices) {
+      Project.NameKey name = projectSlice.getName();
+      int slice = projectSlice.getSlice();
+      int slices = projectSlice.getSlices();
       ListenableFuture<?> future =
           executor.submit(
               reindexProject(
-                  indexerFactory.create(executor, index), project.name, doneTask, failedTask));
-      addErrorListener(future, "project " + project.name, projTask, ok);
+                  indexerFactory.create(executor, index),
+                  name,
+                  slice,
+                  slices,
+                  doneTask,
+                  failedTask));
+      String description = "project " + name + " (" + slice + "/" + slices + ")";
+      addErrorListener(future, description, projTask, ok);
       futures.add(future);
     }
 
@@ -197,22 +238,38 @@
 
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
-    return new ProjectIndexer(indexer, project, done, failed);
+    return reindexProject(indexer, project, 0, 1, done, failed);
+  }
+
+  public Callable<Void> reindexProject(
+      ChangeIndexer indexer,
+      Project.NameKey project,
+      int slice,
+      int slices,
+      Task done,
+      Task failed) {
+    return new ProjectIndexer(indexer, project, slice, slices, done, failed);
   }
 
   private class ProjectIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
     private final Project.NameKey project;
+    private final int slice;
+    private final int slices;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
 
     private ProjectIndexer(
         ChangeIndexer indexer,
         Project.NameKey project,
+        int slice,
+        int slices,
         ProgressMonitor done,
         ProgressMonitor failed) {
       this.indexer = indexer;
       this.project = project;
+      this.slice = slice;
+      this.slices = slices;
       this.done = done;
       this.failed = failed;
     }
@@ -227,7 +284,7 @@
         // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
         // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
         // we don't have concrete proof that improving packfile locality would help.
-        notesFactory.scan(repo, project).forEach(r -> index(r));
+        notesFactory.scan(repo, project, id -> (id.get() % slices) == slice).forEach(r -> index(r));
       } catch (RepositoryNotFoundException rnfe) {
         logger.atSevere().log(rnfe.getMessage());
       } finally {
@@ -244,7 +301,8 @@
       try {
         indexer.index(changeDataFactory.create(r.notes()));
         done.update(1);
-        verboseWriter.println("Reindexed change " + r.id());
+        verboseWriter.format(
+            "Reindexed change %d (project: %s)\n", r.id().get(), r.notes().getProjectName().get());
       } catch (RejectedExecutionException e) {
         // Server shutdown, don't spam the logs.
         failSilently();
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 3bb4770..1a4a335 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -31,116 +31,126 @@
 /** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */
 @AutoValue
 public abstract class Metadata {
-  // The numeric ID of an account.
+  /** The numeric ID of an account. */
   public abstract Optional<Integer> accountId();
 
-  // The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
-  // PLUGIN_UPDATE).
+  /**
+   * The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
+   * PLUGIN_UPDATE).
+   */
   public abstract Optional<String> actionType();
 
-  // An authentication domain name.
+  /** An authentication domain name. */
   public abstract Optional<String> authDomainName();
 
-  // The name of a branch.
+  /** The name of a branch. */
   public abstract Optional<String> branchName();
 
-  // Key of an entity in a cache.
+  /** Key of an entity in a cache. */
   public abstract Optional<String> cacheKey();
 
-  // The name of a cache.
+  /** The name of a cache. */
   public abstract Optional<String> cacheName();
 
-  // The name of the implementation class.
+  /** The name of the implementation class. */
   public abstract Optional<String> className();
 
-  // The numeric ID of a change.
+  /** The numeric ID of a change. */
   public abstract Optional<Integer> changeId();
 
-  // The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+  /**
+   * The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+   */
   public abstract Optional<String> changeIdType();
 
-  // The cause of an error.
+  /** The cause of an error. */
   public abstract Optional<String> cause();
 
-  // The type of an event.
+  /** The SHA1 of a commit. */
+  public abstract Optional<String> commit();
+
+  /** The type of an event. */
   public abstract Optional<String> eventType();
 
-  // The value of the @Export annotation which was used to register a plugin extension.
+  /** The value of the @Export annotation which was used to register a plugin extension. */
   public abstract Optional<String> exportValue();
 
-  // Path of a file in a repository.
+  /** Path of a file in a repository. */
   public abstract Optional<String> filePath();
 
-  // Garbage collector name.
+  /** Garbage collector name. */
   public abstract Optional<String> garbageCollectorName();
 
-  // Git operation (CLONE, FETCH).
+  /** Git operation (CLONE, FETCH). */
   public abstract Optional<String> gitOperation();
 
-  // The numeric ID of an internal group.
+  /** The numeric ID of an internal group. */
   public abstract Optional<Integer> groupId();
 
-  // The name of a group.
+  /** The name of a group. */
   public abstract Optional<String> groupName();
 
-  // The UUID of a group.
+  /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
-  // HTTP status response code.
+  /** HTTP status response code. */
   public abstract Optional<Integer> httpStatus();
 
-  // The name of a secondary index.
+  /** The name of a secondary index. */
   public abstract Optional<String> indexName();
 
-  // The version of a secondary index.
+  /** The version of a secondary index. */
   public abstract Optional<Integer> indexVersion();
 
-  // The name of the implementation method.
+  /** The name of the implementation method. */
   public abstract Optional<String> methodName();
 
-  // One or more resources
+  /** One or more resources */
   public abstract Optional<Boolean> multiple();
 
-  // The name of an operation that is performed.
+  /** The name of an operation that is performed. */
   public abstract Optional<String> operationName();
 
-  // Partial or full computation
+  /** Partial or full computation */
   public abstract Optional<Boolean> partial();
 
-  // Path of a metadata file in NoteDb.
+  /** If a value is still current or not */
+  public abstract Optional<Boolean> outdated();
+
+  /** Path of a metadata file in NoteDb. */
   public abstract Optional<String> noteDbFilePath();
 
-  // Name of a metadata ref in NoteDb.
+  /** Name of a metadata ref in NoteDb. */
   public abstract Optional<String> noteDbRefName();
 
-  // Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS).
+  /** Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS). */
   public abstract Optional<String> noteDbSequenceType();
 
-  // The ID of a patch set.
+  /** The ID of a patch set. */
   public abstract Optional<Integer> patchSetId();
 
-  // Plugin metadata that doesn't fit into any other category.
+  /** Plugin metadata that doesn't fit into any other category. */
   public abstract ImmutableList<PluginMetadata> pluginMetadata();
 
-  // The name of a plugin.
+  /** The name of a plugin. */
   public abstract Optional<String> pluginName();
 
-  // The name of a Gerrit project (aka Git repository).
+  /** The name of a Gerrit project (aka Git repository). */
   public abstract Optional<String> projectName();
 
-  // The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE).
+  /** The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE). */
   public abstract Optional<String> pushType();
 
-  // The number of resources that is processed.
+  /** The number of resources that is processed. */
   public abstract Optional<Integer> resourceCount();
 
-  // The name of a REST view.
+  /** The name of a REST view. */
   public abstract Optional<String> restViewName();
 
-  // The SHA1 of Git commit.
+  /** The SHA1 of Git commit. */
   public abstract Optional<String> revision();
 
-  // The username of an account.
+  /** The username of an account. */
   public abstract Optional<String> username();
 
   /**
@@ -275,6 +285,8 @@
 
     public abstract Builder cause(@Nullable String cause);
 
+    public abstract Builder commit(@Nullable String commit);
+
     public abstract Builder eventType(@Nullable String eventType);
 
     public abstract Builder exportValue(@Nullable String exportValue);
@@ -305,6 +317,8 @@
 
     public abstract Builder partial(boolean partial);
 
+    public abstract Builder outdated(boolean outdated);
+
     public abstract Builder noteDbFilePath(@Nullable String noteDbFilePath);
 
     public abstract Builder noteDbRefName(@Nullable String noteDbRefName);
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 9c3dd02..f004e4b 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -115,6 +116,7 @@
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   public MailProcessor(
@@ -133,7 +135,8 @@
       CommentAdded commentAdded,
       AccountCache accountCache,
       DynamicItem<UrlFormatter> urlFormatter,
-      PluginSetContext<CommentValidator> commentValidators) {
+      PluginSetContext<CommentValidator> commentValidators,
+      MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
     this.emailRejectionSender = emailRejectionSender;
     this.retryHelper = retryHelper;
@@ -150,6 +153,7 @@
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.commentValidators = commentValidators;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   /**
@@ -222,6 +226,7 @@
     try {
       InboundEmailRejectionSender em =
           emailRejectionSender.create(message.from(), message.id(), reason);
+      em.setMessageId(messageIdGenerator.fromMailMessage(message));
       em.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
@@ -366,7 +371,8 @@
               changeMessage,
               comments,
               patchSetComment,
-              ImmutableList.of())
+              ImmutableList.of(),
+              ctx.getRepoView())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 3b7b2aa..e02f02a 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -35,11 +35,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = sshKey;
     this.gpgKeys = null;
@@ -47,8 +52,12 @@
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeys) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted List<String> gpgKeys) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = null;
     this.gpgKeys = gpgKeys;
@@ -58,6 +67,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index deaa926..f3cccf2 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -93,6 +93,11 @@
       return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
     }
 
+    /** @return a web link to the findings tab view of a change. */
+    public String getFindingsTabLink() {
+      return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
+    }
+
     /**
      * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
      */
@@ -386,9 +391,7 @@
 
     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
-      if (group.filename.equals(Patch.PATCHSET_LEVEL)) {
-        groupData.put("link", group.getCommentsTabLink());
-      } else {
+      if (!group.filename.equals(Patch.PATCHSET_LEVEL)) {
         groupData.put("link", group.getFileLink());
       }
       groupData.put("title", group.getTitle());
@@ -420,7 +423,11 @@
         // Set the comment link.
 
         if (comment.key.filename.equals(Patch.PATCHSET_LEVEL)) {
-          commentData.put("link", group.getCommentsTabLink());
+          if (comment instanceof RobotComment) {
+            commentData.put("link", group.getFindingsTabLink());
+          } else {
+            commentData.put("link", group.getCommentsTabLink());
+          }
         } else if (comment.lineNbr == 0) {
           commentData.put("link", group.getFileLink());
         } else {
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index 3df7f05..ce336ff 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -38,11 +38,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public DeleteKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = Collections.emptyList();
     this.sshKey = sshKey;
@@ -51,9 +56,11 @@
   @AssistedInject
   public DeleteKeySender(
       EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser user,
       @Assisted List<String> gpgKeyFingerprints) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = gpgKeyFingerprints;
     this.sshKey = null;
@@ -63,6 +70,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index cec2bb5..bca5338 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -29,11 +30,16 @@
 
   private final IdentifiedUser user;
   private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public HttpPasswordUpdateSender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted String operation) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted String operation) {
     super(args, "HttpPasswordUpdate");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.operation = operation;
   }
@@ -42,6 +48,9 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
new file mode 100644
index 0000000..3a411dc
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.RepoView;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/** A generator class that creates a {@link MessageId} */
+public class MessageIdGenerator {
+  private final GitRepositoryManager repositoryManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public MessageIdGenerator(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
+    this.repositoryManager = repositoryManager;
+    this.allUsersName = allUsersName;
+  }
+
+  /**
+   * A unique id used which is a part of the header of all emails sent through by Gerrit. All of the
+   * emails are sent via {@link OutgoingEmail#send()}.
+   */
+  @AutoValue
+  public abstract static class MessageId {
+    public abstract String id();
+  }
+
+  /**
+   * Create a {@link MessageId} as a result of a change update.
+   *
+   * @param repoView
+   * @param patchsetId
+   * @return MessageId that depends on the patchset.
+   */
+  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Optional<ObjectId> metaSha1;
+    try {
+      metaSha1 = repoView.getRef(metaRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+    return metaSha1
+        .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName()))
+        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
+  }
+
+  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Ref ref = getRef(metaRef, project);
+    checkState(ref != null, metaRef + " must exist");
+    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
+  }
+
+  /**
+   * @param accountId Create a {@link MessageId} as a result of an account update.
+   * @return MessageId that depends on the account id.
+   */
+  public MessageId fromAccountUpdate(Account.Id accountId) {
+    String userRef = RefNames.refsUsers(accountId);
+    Ref ref = getRef(userRef, allUsersName);
+    checkState(ref != null, userRef + " must exist");
+    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
+  }
+
+  /**
+   * Create a {@link MessageId} from a mail message.
+   *
+   * @param mailMessage The message that was sent but was rejected.
+   * @return MessageId that depends on the MailMessage that was rejected.
+   */
+  public MessageId fromMailMessage(MailMessage mailMessage) {
+    return new AutoValue_MessageIdGenerator_MessageId(mailMessage.id() + "-REJECTION");
+  }
+
+  /**
+   * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
+   *
+   * @param reason for performing this account update
+   * @param accountId
+   * @param timestamp
+   * @return MessageId that depends on the reason, accountId, and timestamp.
+   */
+  public MessageId fromReasonAccountIdAndTimestamp(
+      String reason, Account.Id accountId, Instant timestamp) {
+    return new AutoValue_MessageIdGenerator_MessageId(
+        reason + "-" + accountId.toString() + "-" + timestamp.toString());
+  }
+
+  private Ref getRef(String userRef, Project.NameKey project) {
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      return repository.getRefDatabase().findRef(userRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 83c3a94..d81dca4 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -57,7 +57,6 @@
     super.init();
 
     String threadId = getChangeMessageThreadId();
-    setHeader("Message-ID", threadId);
     setHeader("References", threadId);
 
     switch (notify.handling()) {
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index b35bbec..8f63177 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -67,6 +67,7 @@
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
+  private MessageIdGenerator.MessageId messageId;
   protected Map<String, Object> soyContext;
   protected Map<String, Object> soyContextEmailData;
   protected List<String> footers;
@@ -88,6 +89,10 @@
     this.notify = requireNonNull(notify);
   }
 
+  public void setMessageId(MessageIdGenerator.MessageId messageId) {
+    this.messageId = messageId;
+  }
+
   /**
    * Format and enqueue the message for delivery.
    *
@@ -108,6 +113,9 @@
     }
 
     init();
+    if (messageId == null) {
+      throw new IllegalStateException("All emails must have a messageId");
+    }
     if (useHtml()) {
       appendHtml(soyHtmlTemplate("HeaderHtml"));
     }
@@ -201,31 +209,21 @@
         va.htmlBody = null;
       }
 
-      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
-        try {
-          validator.validateOutgoingEmail(va);
-        } catch (ValidationException e) {
-          logger.atFine().log(
-              "Not sending '%s': Rejected by outgoing email validator: %s",
-              messageClass, e.getMessage());
-          return;
-        }
-      }
-
       Set<Address> intersection = Sets.intersection(va.smtpRcptTo, smtpRcptToPlaintextOnly);
       if (!intersection.isEmpty()) {
         logger.atSevere().log("Email '%s' will be sent twice to %s", messageClass, intersection);
       }
-
       if (!va.smtpRcptTo.isEmpty()) {
         // Send multipart message
+        addMessageId(va, "-HTML");
+        if (!validateEmail(va)) return;
         logger.atFine().log(
             "Sending multipart '%s' from %s to %s",
             messageClass, va.smtpFromAddress, va.smtpRcptTo);
         args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
       }
-
       if (!smtpRcptToPlaintextOnly.isEmpty()) {
+        addMessageId(va, "-PLAIN");
         // Send plaintext message
         Map<String, EmailHeader> shallowCopy = new HashMap<>();
         shallowCopy.putAll(headers);
@@ -238,6 +236,7 @@
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
+        if (!validateEmail(va)) return;
         logger.atFine().log(
             "Sending plaintext '%s' from %s to %s",
             messageClass, va.smtpFromAddress, smtpRcptToPlaintextOnly);
@@ -246,6 +245,29 @@
     }
   }
 
+  private boolean validateEmail(OutgoingEmailValidationListener.Args va) {
+    for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
+      try {
+        validator.validateOutgoingEmail(va);
+      } catch (ValidationException e) {
+        logger.atFine().log(
+            "Not sending '%s': Rejected by outgoing email validator: %s",
+            messageClass, e.getMessage());
+        return false;
+      }
+    }
+    return true;
+  }
+
+  // All message ids must start with < and end with >. Also, they must have @domain and no spaces.
+  private void addMessageId(OutgoingEmailValidationListener.Args va, String suffix) {
+    if (messageId != null) {
+      String message = "<" + messageId.id() + suffix + "@" + getGerritHost() + ">";
+      message = message.replaceAll("\\s", "");
+      va.headers.put(FieldName.MESSAGE_ID, new EmailHeader.String(message));
+    }
+  }
+
   /** Format the message body by calling {@link #appendText(String)}. */
   protected abstract void format() throws EmailException;
 
@@ -262,7 +284,6 @@
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
     headers.put(FieldName.CC, new EmailHeader.AddressList());
-    setHeader(FieldName.MESSAGE_ID, "");
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 7136d2b..8e6606e 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -110,7 +110,7 @@
       ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil.newIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
+      return noteUtil.newAccountIdIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 86b6ed7..15f187a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -69,16 +69,38 @@
     return changeNoteJson;
   }
 
-  public PersonIdent newIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
-    return new PersonIdent(
-        getUsername(accountId), getEmailAddress(accountId), when, serverIdent.getTimeZone());
+  /**
+   * Generates a user identifier that contains the account ID, but not the user's name or email
+   * address.
+   *
+   * @return The passed in {@link StringBuilder} instance to which the identifier has been appended.
+   */
+  StringBuilder appendAccountIdIdentString(StringBuilder stringBuilder, Account.Id accountId) {
+    return stringBuilder
+        .append(getAccountIdAsUsername(accountId))
+        .append(" <")
+        .append(getAccountIdAsEmailAddress(accountId))
+        .append('>');
   }
 
-  private static String getUsername(Account.Id accountId) {
+  /**
+   * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
+   * address.
+   */
+  public PersonIdent newAccountIdIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        getAccountIdAsUsername(accountId),
+        getAccountIdAsEmailAddress(accountId),
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
+  public static String getAccountIdAsUsername(Account.Id accountId) {
     return "Gerrit User " + accountId.toString();
   }
 
-  private String getEmailAddress(Account.Id accountId) {
+  private String getAccountIdAsEmailAddress(Account.Id accountId) {
     return accountId.get() + "@" + serverId;
   }
 
@@ -198,21 +220,10 @@
   }
 
   String attentionSetUpdateToJson(AttentionSetUpdate attentionSetUpdate) {
-    PersonIdent personIdent =
-        new PersonIdent(
-            getUsername(attentionSetUpdate.account()),
-            getEmailAddress(attentionSetUpdate.account()));
     StringBuilder stringBuilder = new StringBuilder();
-    appendIdentString(stringBuilder, personIdent.getName(), personIdent.getEmailAddress());
+    appendAccountIdIdentString(stringBuilder, attentionSetUpdate.account());
     return gson.toJson(
         new AttentionStatusInNoteDb(
             stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
   }
-
-  static void appendIdentString(StringBuilder stringBuilder, String name, String emailAddress) {
-    PersonIdent.appendSanitized(stringBuilder, name);
-    stringBuilder.append(" <");
-    PersonIdent.appendSanitized(stringBuilder, emailAddress);
-    stringBuilder.append('>');
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 0a71fa1..36a61cc0 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -207,9 +207,19 @@
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
         throws IOException {
+      return scan(repo, project, null);
+    }
+
+    public Stream<ChangeNotesResult> scan(
+        Repository repo, Project.NameKey project, Predicate<Change.Id> changeIdPredicate)
+        throws IOException {
       ScanResult sr = scanChangeIds(repo);
 
-      return sr.all().stream().map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
+      Stream<Change.Id> idStream = sr.all().stream();
+      if (changeIdPredicate != null) {
+        idStream = idStream.filter(changeIdPredicate);
+      }
+      return idStream.map(id -> scanOneChange(project, sr, id)).filter(Objects::nonNull);
     }
 
     @Nullable
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 63f4e5d..41b1b94 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -602,7 +602,7 @@
     if (assignee != null) {
       if (assignee.isPresent()) {
         addFooter(msg, FOOTER_ASSIGNEE);
-        addIdent(msg, assignee.get()).append('\n');
+        noteUtil.appendAccountIdIdentString(msg, assignee.get()).append('\n');
       } else {
         addFooter(msg, FOOTER_ASSIGNEE).append('\n');
       }
@@ -623,7 +623,7 @@
 
     for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) {
       addFooter(msg, e.getValue().getFooterKey());
-      addIdent(msg, e.getKey()).append('\n');
+      noteUtil.appendAccountIdIdentString(msg, e.getKey()).append('\n');
     }
 
     for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
@@ -640,7 +640,7 @@
       }
       Account.Id id = c.getColumnKey();
       if (!id.equals(getAccountId())) {
-        addIdent(msg.append(' '), id);
+        noteUtil.appendAccountIdIdentString(msg.append(' '), id);
       }
       msg.append('\n');
     }
@@ -666,7 +666,7 @@
                 .append(label.label);
             if (label.appliedBy != null) {
               msg.append(": ");
-              addIdent(msg, label.appliedBy);
+              noteUtil.appendAccountIdIdentString(msg, label.appliedBy);
             }
             msg.append('\n');
           }
@@ -677,7 +677,7 @@
 
     if (!Objects.equals(accountId, realAccountId)) {
       addFooter(msg, FOOTER_REAL_USER);
-      addIdent(msg, realAccountId).append('\n');
+      noteUtil.appendAccountIdIdentString(msg, realAccountId).append('\n');
     }
 
     if (isPrivate != null) {
@@ -799,10 +799,4 @@
   private static boolean isIllegalTopic(String topic) {
     return (topic != null && topic.contains("\""));
   }
-
-  private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
-    PersonIdent ident = noteUtil.newIdent(accountId, when, serverIdent);
-    ChangeNoteUtil.appendIdentString(sb, ident.getName(), ident.getEmailAddress());
-    return sb;
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 128e185..1ead03c 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -27,6 +27,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.function.Consumer;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -47,12 +48,16 @@
 
   private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
   private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-  private static final int CHUNK_SIZE = 100; // log progress after deleting every CHUNK_SIZE refs
+
+  // Number of refs deleted at once in a batch ref-update.
+  // Log progress after deleting every CHUNK_SIZE refs
+  private static final int CHUNK_SIZE = 3000;
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
   private Repository allUsersRepo;
+  private final Consumer<String> uiConsumer;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
@@ -63,9 +68,18 @@
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
       @Assisted Integer cleanupPercentage) {
+    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+  }
+
+  public DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      Consumer<String> uiConsumer) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
+    this.uiConsumer = uiConsumer;
   }
 
   public void execute() throws IOException {
@@ -74,15 +88,17 @@
     List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
     List<Ref> zombieRefs = filterZombieRefs(draftRefs);
 
-    logger.atInfo().log(
-        "Found a total of %d zombie draft refs in %s repo.", zombieRefs.size(), allUsers.get());
+    logInfo(
+        String.format(
+            "Found a total of %d zombie draft refs in %s repo.",
+            zombieRefs.size(), allUsers.get()));
 
-    logger.atInfo().log("Cleanup percentage = %d", cleanupPercentage);
+    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
     zombieRefs =
         zombieRefs.stream()
             .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
             .collect(toImmutableList());
-    logger.atInfo().log("Number of zombie refs to be cleaned = %d", zombieRefs.size());
+    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
 
     long zombieRefsCnt = zombieRefs.size();
     long deletedRefsCnt = 0;
@@ -124,8 +140,15 @@
     return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
   }
 
+  private void logInfo(String message) {
+    logger.atInfo().log(message);
+    uiConsumer.accept(message);
+  }
+
   private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
-    logger.atInfo().log(
-        "Deleted %d/%d zombie draft refs (%d seconds)\n", deletedRefsCount, allRefsCount, elapsed);
+    logInfo(
+        String.format(
+            "Deleted %d/%d zombie draft refs (%d seconds)",
+            deletedRefsCount, allRefsCount, elapsed));
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/IntBlob.java b/java/com/google/gerrit/server/notedb/IntBlob.java
index 61b9ae0..ec5a83a 100644
--- a/java/com/google/gerrit/server/notedb/IntBlob.java
+++ b/java/com/google/gerrit/server/notedb/IntBlob.java
@@ -20,6 +20,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
@@ -42,6 +43,8 @@
 /** An object blob in a Git repository that stores a single integer value. */
 @AutoValue
 public abstract class IntBlob {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static Optional<IntBlob> parse(Repository repo, String refName) throws IOException {
     try (ObjectReader or = repo.newObjectReader()) {
       return parse(repo, refName, or);
@@ -85,8 +88,13 @@
       throws IOException {
     ObjectId newId;
     try (ObjectInserter ins = repo.newObjectInserter()) {
+      logger.atFine().log(
+          "storing value %d on %s in %s (oldId: %s)",
+          val, refName, projectName, oldId == null ? "null" : oldId.name());
       newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
       ins.flush();
+      logger.atFine().log(
+          "successfully stored %d on %s as %s in %s", val, refName, newId.name(), projectName);
     }
     RefUpdate ru = repo.updateRef(refName);
     if (oldId != null) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 2d1a04a..ca97a1a 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.logging.TraceContext.newTimer;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -31,6 +32,8 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -307,8 +310,15 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
-      execute(allUsersRepo, dryrun, null);
+      BatchRefUpdate result;
+      try (TraceContext.TraceTimer ignored =
+          newTimer("NoteDbUpdateManager#updateRepo", Metadata.empty())) {
+        result = execute(changeRepo, dryrun, pushCert);
+      }
+      try (TraceContext.TraceTimer ignored =
+          newTimer("NoteDbUpdateManager#updateAllUsersSync", Metadata.empty())) {
+        execute(allUsersRepo, dryrun, null);
+      }
       if (!dryrun) {
         // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
         // have to run synchronous to be of any value at all. For the removal of draft comments from
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 11ba8cd..47e12ff 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -30,6 +30,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -64,6 +65,8 @@
  * numbers.
  */
 public class RepoSequence {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   @FunctionalInterface
   public interface Seed {
     int get();
@@ -185,6 +188,7 @@
     this.afterReadRef = requireNonNull(afterReadRef, "afterReadRef");
     this.retryer = requireNonNull(retryer, "retryer");
 
+    logger.atFine().log("sequence batch size for %s is %s", name, batchSize);
     counterLock = new ReentrantLock(true);
   }
 
@@ -263,6 +267,7 @@
   private void acquire(int count) {
     try (Repository repo = repoManager.openRepository(projectName);
         RevWalk rw = new RevWalk(repo)) {
+      logger.atFine().log("acquire %d ids on %s in %s", count, refName, projectName);
       Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
       afterReadRef.run();
       ObjectId oldId;
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
index be68592..7a8e28f 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -32,6 +32,11 @@
 
 @Singleton
 public class Sequences {
+  private static final String SECTION_NOTEDB = "noteDb";
+  private static final String KEY_SEQUENCE_BATCH_SIZE = "sequenceBatchSize";
+  private static final int DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE = 1;
+  private static final int DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE = 20;
+
   public static final String NAME_ACCOUNTS = "accounts";
   public static final String NAME_GROUPS = "groups";
   public static final String NAME_CHANGES = "changes";
@@ -60,7 +65,12 @@
       AllUsersName allUsers,
       MetricMaker metrics) {
 
-    int accountBatchSize = cfg.getInt("noteDb", "accounts", "sequenceBatchSize", 1);
+    int accountBatchSize =
+        cfg.getInt(
+            SECTION_NOTEDB,
+            NAME_ACCOUNTS,
+            KEY_SEQUENCE_BATCH_SIZE,
+            DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE);
     accountSeq =
         new RepoSequence(
             repoManager,
@@ -70,7 +80,12 @@
             () -> FIRST_ACCOUNT_ID,
             accountBatchSize);
 
-    int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
+    int changeBatchSize =
+        cfg.getInt(
+            SECTION_NOTEDB,
+            NAME_CHANGES,
+            KEY_SEQUENCE_BATCH_SIZE,
+            DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE);
     changeSeq =
         new RepoSequence(
             repoManager,
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index eb6a280..b9e644f 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -31,7 +31,7 @@
   @Provides
   @Singleton
   @DiffExecutor
-  public ExecutorService createDiffExecutor() {
+  public ExecutorService provideDiffExecutor() {
     return new LoggingContextAwareExecutorService(
         Executors.newCachedThreadPool(
             new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build()));
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 4e1a30c..37de0d1 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -569,7 +569,12 @@
     // even if the change is not part of the set of most recent changes that
     // SearchingChangeCacheImpl returns.
     Change.Id cId = Change.Id.fromRef(refName);
-    requireNonNull(cId, () -> String.format("invalid change id for ref %s", refName));
+    if (cId == null) {
+      // The ref is not a valid change ref. Treat it as non-visible since it's not representing a
+      // change.
+      logger.atWarning().log("invalid change ref %s is not visible", refName);
+      return false;
+    }
     ChangeNotes notes;
     try {
       notes = changeNotesFactory.create(projectState.getNameKey(), cId);
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 5b80059..82f97c9 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -110,9 +110,6 @@
 
   public static Path storeInTemp(String pluginName, InputStream in, SitePaths sitePaths)
       throws IOException {
-    if (!Files.exists(sitePaths.tmp_dir)) {
-      Files.createDirectories(sitePaths.tmp_dir);
-    }
     return PluginUtil.asTemp(in, tempNameFor(pluginName), ".jar", sitePaths.tmp_dir);
   }
 
diff --git a/java/com/google/gerrit/server/plugins/PluginUtil.java b/java/com/google/gerrit/server/plugins/PluginUtil.java
index d4110ca..932a01d 100644
--- a/java/com/google/gerrit/server/plugins/PluginUtil.java
+++ b/java/com/google/gerrit/server/plugins/PluginUtil.java
@@ -53,6 +53,7 @@
   }
 
   static Path asTemp(InputStream in, String prefix, String suffix, Path dir) throws IOException {
+    Files.createDirectories(dir);
     Path tmp = Files.createTempFile(dir, prefix, suffix);
     boolean keep = false;
     try (OutputStream out = Files.newOutputStream(tmp)) {
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
deleted file mode 100644
index eb451fd..0000000
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-import org.eclipse.jgit.lib.Config;
-
-/** Ticks periodically to force refresh events for {@link ProjectCacheImpl}. */
-@Singleton
-public class ProjectCacheClock implements LifecycleListener {
-  private final Config serverConfig;
-
-  private final AtomicLong generation = new AtomicLong();
-
-  private ScheduledExecutorService executor;
-
-  @Inject
-  public ProjectCacheClock(@GerritServerConfig Config serverConfig) {
-    this.serverConfig = serverConfig;
-  }
-
-  @Override
-  public void start() {
-    long checkFrequencyMillis = checkFrequency(serverConfig);
-
-    if (checkFrequencyMillis == Long.MAX_VALUE) {
-      // Start with generation 1 (to avoid magic 0 below).
-      // Do not begin background thread, disabling the clock.
-      generation.set(1);
-    } else if (10 < checkFrequencyMillis) {
-      // Start with generation 1 (to avoid magic 0 below).
-      generation.set(1);
-      executor =
-          new LoggingContextAwareScheduledExecutorService(
-              Executors.newScheduledThreadPool(
-                  1,
-                  new ThreadFactoryBuilder()
-                      .setNameFormat("ProjectCacheClock-%d")
-                      .setDaemon(true)
-                      .setPriority(Thread.MIN_PRIORITY)
-                      .build()));
-      @SuppressWarnings("unused") // Runnable already handles errors
-      Future<?> possiblyIgnoredError =
-          executor.scheduleAtFixedRate(
-              generation::incrementAndGet,
-              checkFrequencyMillis,
-              checkFrequencyMillis,
-              TimeUnit.MILLISECONDS);
-    } else {
-      // Magic generation 0 triggers ProjectState to always
-      // check on each needsRefresh() request we make to it.
-      generation.set(0);
-    }
-  }
-
-  @Override
-  public void stop() {
-    if (executor != null) {
-      executor.shutdown();
-    }
-  }
-
-  long read() {
-    return generation.get();
-  }
-
-  private static long checkFrequency(Config serverConfig) {
-    String freq = serverConfig.getString("cache", "projects", "checkFrequency");
-    if (freq != null && ("disabled".equalsIgnoreCase(freq) || "off".equalsIgnoreCase(freq))) {
-      return Long.MAX_VALUE;
-    }
-    return TimeUnit.MILLISECONDS.convert(
-        ConfigUtil.getTimeUnit(
-            serverConfig, "cache", "projects", "checkFrequency", 5, TimeUnit.MINUTES),
-        TimeUnit.MINUTES);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 9d09eeb..a5c07e5 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -24,16 +24,23 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.Counter2;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -48,6 +55,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -55,6 +63,7 @@
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /** Cache of project information, including access rights. */
@@ -70,7 +79,10 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(CACHE_NAME, String.class, ProjectState.class).loader(Loader.class);
+        cache(CACHE_NAME, Project.NameKey.class, ProjectState.class)
+            .loader(Loader.class)
+            .refreshAfterWrite(Duration.ofMinutes(15))
+            .expireAfterWrite(Duration.ofHours(1));
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
             .maximumWeight(1)
@@ -84,7 +96,6 @@
               @Override
               protected void configure() {
                 listener().to(ProjectCacheWarmer.class);
-                listener().to(ProjectCacheClock.class);
               }
             });
       }
@@ -93,10 +104,9 @@
 
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
-  private final LoadingCache<String, ProjectState> byName;
+  private final LoadingCache<Project.NameKey, ProjectState> byName;
   private final LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list;
   private final Lock listLock;
-  private final ProjectCacheClock clock;
   private final Provider<ProjectIndexer> indexer;
   private final Timer0 guessRelevantGroupsLatency;
 
@@ -104,9 +114,8 @@
   ProjectCacheImpl(
       final AllProjectsName allProjectsName,
       final AllUsersName allUsersName,
-      @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
+      @Named(CACHE_NAME) LoadingCache<Project.NameKey, ProjectState> byName,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
-      ProjectCacheClock clock,
       Provider<ProjectIndexer> indexer,
       MetricMaker metricMaker) {
     this.allProjectsName = allProjectsName;
@@ -114,7 +123,6 @@
     this.byName = byName;
     this.list = list;
     this.listLock = new ReentrantLock(true /* fair */);
-    this.clock = clock;
     this.indexer = indexer;
 
     this.guessRelevantGroupsLatency =
@@ -142,13 +150,8 @@
     }
 
     try {
-      ProjectState state = byName.get(projectName.get());
-      if (state != null && state.needsRefresh(clock.read())) {
-        byName.invalidate(projectName.get());
-        state = byName.get(projectName.get());
-      }
-      return Optional.of(state);
-    } catch (Exception e) {
+      return Optional.of(byName.get(projectName));
+    } catch (ExecutionException e) {
       if ((e.getCause() instanceof RepositoryNotFoundException)) {
         logger.atFine().log("Cannot find project %s", projectName.get());
         return Optional.empty();
@@ -167,7 +170,7 @@
   public void evict(Project.NameKey p) {
     if (p != null) {
       logger.atFine().log("Evict project '%s'", p.get());
-      byName.invalidate(p.get());
+      byName.invalidate(p);
     }
     indexer.get().index(p);
   }
@@ -222,7 +225,7 @@
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
       return all().stream()
-          .map(n -> byName.getIfPresent(n.get()))
+          .map(n -> byName.getIfPresent(n))
           .filter(Objects::nonNull)
           .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
           // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
@@ -245,41 +248,67 @@
     }
   }
 
-  static class Loader extends CacheLoader<String, ProjectState> {
+  @Singleton
+  static class Loader extends CacheLoader<Project.NameKey, ProjectState> {
     private final ProjectState.Factory projectStateFactory;
     private final GitRepositoryManager mgr;
-    private final ProjectCacheClock clock;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final ListeningExecutorService cacheRefreshExecutor;
+    private final Counter2<String, Boolean> refreshCounter;
 
     @Inject
     Loader(
         ProjectState.Factory psf,
         GitRepositoryManager g,
-        ProjectCacheClock clock,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        @CacheRefreshExecutor ListeningExecutorService cacheRefreshExecutor,
+        MetricMaker metricMaker) {
       projectStateFactory = psf;
       mgr = g;
-      this.clock = clock;
       this.projectConfigFactory = projectConfigFactory;
+      this.cacheRefreshExecutor = cacheRefreshExecutor;
+      refreshCounter =
+          metricMaker.newCounter(
+              "caches/refresh_count",
+              new Description("count").setRate(),
+              Field.ofString("cache", Metadata.Builder::className).build(),
+              Field.ofBoolean("outdated", Metadata.Builder::outdated).build());
     }
 
     @Override
-    public ProjectState load(String projectName) throws Exception {
+    public ProjectState load(Project.NameKey key) throws Exception {
       try (TraceTimer timer =
           TraceContext.newTimer(
-              "Loading project", Metadata.builder().projectName(projectName).build())) {
-        long now = clock.read();
-        Project.NameKey key = Project.nameKey(projectName);
+              "Loading project", Metadata.builder().projectName(key.get()).build())) {
         try (Repository git = mgr.openRepository(key)) {
           ProjectConfig cfg = projectConfigFactory.create(key);
           cfg.load(key, git);
-
-          ProjectState state = projectStateFactory.create(cfg);
-          state.initLastCheck(now);
-          return state;
+          return projectStateFactory.create(cfg);
         }
       }
     }
+
+    @Override
+    public ListenableFuture<ProjectState> reload(Project.NameKey key, ProjectState oldState)
+        throws Exception {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Reload project", Metadata.builder().projectName(key.get()).build())) {
+        try (Repository git = mgr.openRepository(key)) {
+          Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
+          if (configRef != null
+              && configRef.getObjectId().equals(oldState.getConfig().getRevision())) {
+            refreshCounter.increment(CACHE_NAME, false);
+            return Futures.immediateFuture(oldState);
+          }
+        }
+
+        // Repository is not thread safe, so we have to open it on the thread that does the loading.
+        // Just invoke the loader on the other thread.
+        refreshCounter.increment(CACHE_NAME, true);
+        return cacheRefreshExecutor.submit(() -> load(key));
+      }
+    }
   }
 
   static class ListKey {
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f2254d6..f00df53 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Project;
@@ -34,6 +35,7 @@
 /** Collection of routines to populate {@link ProjectInfo}. */
 @Singleton
 public class ProjectJson {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final AllProjectsName allProjects;
   private final WebLinks webLinks;
@@ -50,7 +52,17 @@
     for (LabelType t : projectState.getLabelTypes().getLabelTypes()) {
       LabelTypeInfo labelInfo = new LabelTypeInfo();
       labelInfo.values =
-          t.getValues().stream().collect(toMap(LabelValue::formatValue, LabelValue::getText));
+          t.getValues().stream()
+              .collect(
+                  toMap(
+                      LabelValue::formatValue,
+                      LabelValue::getText,
+                      (v1, v2) -> {
+                        logger.atSevere().log(
+                            "Duplicate values for project: %s, label: %s found: '%s':'%s'",
+                            projectState.getName(), t.getName(), v1, v2);
+                        return v1;
+                      }));
       labelInfo.defaultValue = t.getDefaultValue();
       info.labels.put(t.getName(), labelInfo);
     }
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index e52f344..efadcc8 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -59,7 +58,6 @@
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -86,9 +84,6 @@
   private final long globalMaxObjectSizeLimit;
   private final boolean inheritProjectMaxObjectSizeLimit;
 
-  /** Last system time the configuration's revision was examined. */
-  private volatile long lastCheckGeneration;
-
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
 
@@ -140,33 +135,6 @@
     }
   }
 
-  void initLastCheck(long generation) {
-    lastCheckGeneration = generation;
-  }
-
-  boolean needsRefresh(long generation) {
-    if (generation <= 0) {
-      return isRevisionOutOfDate();
-    }
-    if (lastCheckGeneration != generation) {
-      lastCheckGeneration = generation;
-      return isRevisionOutOfDate();
-    }
-    return false;
-  }
-
-  private boolean isRevisionOutOfDate() {
-    try (Repository git = gitMgr.openRepository(getNameKey())) {
-      Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
-      if (ref == null || ref.getObjectId() == null) {
-        return true;
-      }
-      return !ref.getObjectId().equals(config.getRevision());
-    } catch (IOException gone) {
-      return true;
-    }
-  }
-
   /**
    * @return cached computation of all global capabilities. This should only be invoked on the state
    *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index fee5eab..89b931f 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -80,6 +81,7 @@
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
+  private final MessageIdGenerator messageIdGenerator;
   private final boolean isDevMode;
 
   @Inject
@@ -91,7 +93,8 @@
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
-      OutgoingEmailValidator validator) {
+      OutgoingEmailValidator validator,
+      MessageIdGenerator messageIdGenerator) {
     this.self = self;
     this.realm = realm;
     this.permissionBackend = permissionBackend;
@@ -100,6 +103,7 @@
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -161,6 +165,7 @@
         if (!sender.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
+        sender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
         sender.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 4c39763..bfe7177 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -77,6 +78,7 @@
   private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   DeleteVote(
@@ -89,7 +91,8 @@
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
@@ -100,6 +103,7 @@
     this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -229,6 +233,8 @@
           cm.setFrom(user.getAccountId());
           cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
           cm.setNotify(notify);
+          cm.setMessageId(
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
           cm.send();
         }
       } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 1e2e644..a16d4f9 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -49,11 +49,11 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
-import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -904,9 +904,23 @@
       }
       NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
       if (notify.shouldNotify()) {
-        email
-            .create(notify, notes, ps, user, message, comments, in.message, labelDelta)
-            .sendAsync();
+        try {
+          email
+              .create(
+                  notify,
+                  notes,
+                  ps,
+                  user,
+                  message,
+                  comments,
+                  in.message,
+                  labelDelta,
+                  ctx.getRepoView())
+              .sendAsync();
+        } catch (IOException ex) {
+          throw new StorageException(
+              String.format("Repository %s not found", ctx.getProject().get()), ex);
+        }
       }
       commentAdded.fire(
           notes.getChange(),
@@ -1271,8 +1285,6 @@
         return false;
       }
 
-      forceCallerAsReviewer(projectState, ctx, current, ups, del);
-
       return !del.isEmpty() || !ups.isEmpty();
     }
 
@@ -1343,41 +1355,6 @@
       }
     }
 
-    private void forceCallerAsReviewer(
-        ProjectState projectState,
-        ChangeContext ctx,
-        Map<String, PatchSetApproval> current,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del) {
-      if (current.isEmpty() && ups.isEmpty()) {
-        // TODO Find another way to link reviewers to changes.
-        if (del.isEmpty()) {
-          // If no existing label is being set to 0, hack in the caller
-          // as a reviewer by picking the first server-wide LabelType.
-          List<LabelType> labelTypes = projectState.getLabelTypes(ctx.getNotes()).getLabelTypes();
-          if (labelTypes.isEmpty()) {
-            logger.atWarning().log(
-                "no label type found for project %s, change %s",
-                projectState.getName(), ctx.getChange().getChangeId());
-            return;
-          }
-
-          LabelId labelId = labelTypes.get(0).getLabelId();
-          ups.add(
-              ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build());
-        } else {
-          // Pick a random label that is about to be deleted and keep it.
-          Iterator<PatchSetApproval> i = del.iterator();
-          ups.add(i.next().toBuilder().value(0).granted(ctx.getWhen()).build());
-          i.remove();
-        }
-      }
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
-    }
-
     private Map<String, PatchSetApproval> scanLabels(
         ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
         throws IOException {
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index a72192e..d1506b7 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -65,6 +66,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeRestored changeRestored;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   Restore(
@@ -74,7 +76,8 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeRestored changeRestored,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.restoredSenderFactory = restoredSenderFactory;
     this.json = json;
@@ -82,6 +85,7 @@
     this.psUtil = psUtil;
     this.changeRestored = changeRestored;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -149,6 +153,8 @@
         ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setChangeMessage(message.getMessage(), ctx.getWhen());
+        cm.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         cm.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 88db66e..e2b898f 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -58,6 +58,7 @@
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
@@ -126,6 +127,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final GetRelated getRelated;
+  private final MessageIdGenerator messageIdGenerator;
 
   private CherryPickInput cherryPickInput;
   private List<ChangeInfo> results;
@@ -154,7 +156,8 @@
       NotifyResolver notifyResolver,
       BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
-      GetRelated getRelated) {
+      GetRelated getRelated,
+      MessageIdGenerator messageIdGenerator) {
     this.queryProvider = queryProvider;
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -175,6 +178,7 @@
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.getRelated = getRelated;
+    this.messageIdGenerator = messageIdGenerator;
     results = new ArrayList<>();
     cherryPickInput = null;
   }
@@ -601,6 +605,8 @@
         RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
         cm.setNotify(ctx.getNotify(change.getId()));
+        cm.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         cm.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 8362e95..c118766 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -75,8 +75,8 @@
   @Override
   public Description getDescription(ChangeResource rsrc) {
     return new Description()
-        .setLabel("Start Review")
-        .setTitle("Set Ready For Review")
+        .setLabel("Mark as Active")
+        .setTitle("Switch change state from WIP to Active (ready for review)")
         .setVisible(
             and(
                 rsrc.getChange().isNew() && rsrc.getChange().isWorkInProgress(),
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 54625a6..54915fb 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -26,9 +26,8 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
-import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
+import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -46,7 +45,7 @@
   private static final String E_UNABLE_TO_FETCH_LABELS =
       "Unable to fetch labels and approvals for the change";
 
-  public static class Module extends FactoryModule {
+  public static class Module extends AbstractModule {
     @Override
     public void configure() {
       bind(SubmitRule.class)
@@ -55,9 +54,6 @@
     }
   }
 
-  @Inject
-  IgnoreSelfApprovalRule() {}
-
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
     List<LabelType> labelTypes;
diff --git a/java/com/google/gerrit/server/rules/PrologOptions.java b/java/com/google/gerrit/server/rules/PrologOptions.java
index da9b3ab..a176f04 100644
--- a/java/com/google/gerrit/server/rules/PrologOptions.java
+++ b/java/com/google/gerrit/server/rules/PrologOptions.java
@@ -15,18 +15,21 @@
 package com.google.gerrit.server.rules;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
 
 @AutoValue
 public abstract class PrologOptions {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static PrologOptions defaultOptions() {
     return new AutoValue_PrologOptions.Builder().logErrors(true).skipFilters(false).build();
   }
 
   public static PrologOptions dryRunOptions(String ruleToTest, boolean skipFilters) {
     return new AutoValue_PrologOptions.Builder()
-        .logErrors(false)
+        .logErrors(logger.atFine().isEnabled())
         .skipFilters(skipFilters)
         .rule(ruleToTest)
         .build();
diff --git a/java/com/google/gerrit/server/schema/AclUtil.java b/java/com/google/gerrit/server/schema/AclUtil.java
index f0aafef..f6c3aad 100644
--- a/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/java/com/google/gerrit/server/schema/AclUtil.java
@@ -105,4 +105,15 @@
   public static PermissionRule rule(ProjectConfig config, GroupReference group) {
     return new PermissionRule(config.resolve(group));
   }
+
+  public static void remove(
+      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+    Permission p = section.getPermission(permission, true);
+    for (GroupReference group : groupList) {
+      if (group != null) {
+        PermissionRule r = rule(config, group);
+        p.remove(r);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index c19be5e..cfa5825 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -174,11 +174,12 @@
       AccessSection heads, LabelType codeReviewLabel, ProjectConfig config) {
     AccessSection refsFor = config.getAccessSection("refs/for/*", true);
     AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
+    AccessSection all = config.getAccessSection("refs/*", true);
 
     grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-    grant(config, heads, Permission.REVERT, registered);
+    grant(config, all, Permission.REVERT, registered);
     grant(config, magic, Permission.PUSH, registered);
     grant(config, magic, Permission.PUSH_MERGE, registered);
   }
diff --git a/java/com/google/gerrit/server/schema/GrantRevertPermission.java b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
index d6228ce..2f890d5 100644
--- a/java/com/google/gerrit/server/schema/GrantRevertPermission.java
+++ b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.schema.AclUtil.grant;
+import static com.google.gerrit.server.schema.AclUtil.remove;
 
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GroupReference;
@@ -64,12 +65,26 @@
       ProjectConfig projectConfig = projectConfigFactory.read(md);
       AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
 
-      Permission permission = heads.getPermission(Permission.REVERT);
-      if (permission != null && permission.getRule(registeredUsers) != null) {
-        // permission already exists, don't do anything.
+      Permission permissionOnRefsHeads = heads.getPermission(Permission.REVERT);
+
+      if (permissionOnRefsHeads != null) {
+        if (permissionOnRefsHeads.getRule(registeredUsers) == null
+            || permissionOnRefsHeads.getRules().size() > 1) {
+          // If admins already changed the permission, don't do anything.
+          return;
+        }
+        // permission already exists in refs/heads/*, delete it for Registered Users.
+        remove(projectConfig, heads, Permission.REVERT, registeredUsers);
+      }
+
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
+      Permission permissionOnRefsStar = all.getPermission(Permission.REVERT);
+      if (permissionOnRefsStar != null && permissionOnRefsStar.getRule(registeredUsers) != null) {
+        // permission already exists in refs/*, don't do anything.
         return;
       }
-      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+      // If the permission doesn't exist of refs/* for Registered Users, grant it.
+      grant(projectConfig, all, Permission.REVERT, registeredUsers);
 
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java
index 33534fc..0360ec0 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java
@@ -14,15 +14,20 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.ProvisionException;
+import org.eclipse.jgit.lib.Config;
 
 public class NoteDbSchemaVersionCheck implements LifecycleListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static Module module() {
     return new LifecycleModule() {
       @Override
@@ -34,11 +39,16 @@
 
   private final NoteDbSchemaVersionManager versionManager;
   private final SitePaths sitePaths;
+  private Config gerritConfig;
 
   @Inject
-  NoteDbSchemaVersionCheck(NoteDbSchemaVersionManager versionManager, SitePaths sitePaths) {
+  NoteDbSchemaVersionCheck(
+      NoteDbSchemaVersionManager versionManager,
+      SitePaths sitePaths,
+      @GerritServerConfig Config gerritConfig) {
     this.versionManager = versionManager;
     this.sitePaths = sitePaths;
+    this.gerritConfig = gerritConfig;
   }
 
   @Override
@@ -53,7 +63,18 @@
                 sitePaths.site_path.toAbsolutePath()));
       }
       int expected = NoteDbSchemaVersions.LATEST;
-      if (current != expected) {
+
+      if (current > expected
+          && gerritConfig.getBoolean("gerrit", "experimentalRollingUpgrade", false)) {
+        logger.atWarning().log(
+            "Gerrit has detected refs/meta/version %d different than the expected %d."
+                + "Bear in mind that this is supported ONLY for rolling upgrades to immediate next "
+                + "Gerrit version (e.g. v3.1 to v3.2). If this is not expected, remove gerrit.experimentalRollingUpgrade "
+                + "from $GERRIT_SITE/etc/gerrit.config and restart Gerrit."
+                + "Please note that gerrit.experimentalRollingUpgrade is intended to be used "
+                + "for the rolling upgrade phase only and should be disabled afterwards.",
+            current, expected);
+      } else if (current != expected) {
         String advice =
             current > expected
                 ? "Downgrade is not supported"
diff --git a/java/com/google/gerrit/server/schema/Schema_182.java b/java/com/google/gerrit/server/schema/Schema_182.java
index 3928b2b..a61a175 100644
--- a/java/com/google/gerrit/server/schema/Schema_182.java
+++ b/java/com/google/gerrit/server/schema/Schema_182.java
@@ -28,7 +28,8 @@
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
     AllUsersName allUsers = args.allUsers;
     GitRepositoryManager gitRepoManager = args.repoManager;
-    DeleteZombieCommentsRefs cleanup = new DeleteZombieCommentsRefs(allUsers, gitRepoManager, 100);
+    DeleteZombieCommentsRefs cleanup =
+        new DeleteZombieCommentsRefs(allUsers, gitRepoManager, 100, ui::message);
     cleanup.execute();
   }
 }
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 606d851..8b159bc 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -55,6 +55,7 @@
           "[access \"refs/*\"]",
           "  read = group Administrators",
           "  read = group Anonymous Users",
+          "  revert = group Registered Users",
           "[access \"refs/for/*\"]",
           "  addPatchSet = group Registered Users",
           "[access \"refs/for/refs/*\"]",
@@ -75,7 +76,6 @@
           "  push = group Project Owners",
           "  submit = group Administrators",
           "  submit = group Project Owners",
-          "  revert = group Registered Users",
           "[access \"refs/meta/config\"]",
           "  exclusiveGroupPermissions = read",
           "  create = group Administrators",
diff --git a/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index 0a6bcac..1159e06 100644
--- a/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -40,7 +40,7 @@
   @Provides
   @Singleton
   @SshListenAddresses
-  public List<SocketAddress> getListenAddresses(@GerritServerConfig Config cfg) {
+  public List<SocketAddress> provideListenAddresses(@GerritServerConfig Config cfg) {
     List<SocketAddress> listen = Lists.newArrayListWithExpectedSize(2);
     String[] want = cfg.getStringList("sshd", null, "listenaddress");
     if (want == null || want.length == 0) {
@@ -71,7 +71,7 @@
   @Provides
   @Singleton
   @SshAdvertisedAddresses
-  List<String> getAdvertisedAddresses(
+  List<String> provideAdvertisedAddresses(
       @GerritServerConfig Config cfg, @SshListenAddresses List<SocketAddress> listen) {
     String[] want = cfg.getStringList("sshd", null, "advertisedaddress");
     if (want.length > 0) {
diff --git a/java/com/google/gerrit/server/submit/BranchTips.java b/java/com/google/gerrit/server/submit/BranchTips.java
new file mode 100644
index 0000000..d42517c
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/BranchTips.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Ref;
+
+/**
+ * Current branch tips, taking into account commits created during the submit process as well as
+ * submodule updates produced by this class.
+ */
+class BranchTips {
+
+  private final Map<BranchNameKey, CodeReviewCommit> branchTips = new HashMap<>();
+
+  /**
+   * Returns current tip of the branch, taking into account commits created during the submit
+   * process or submodule updates.
+   *
+   * @param branch branch
+   * @param repo repository to look for the branch if not cached
+   * @return the current tip. Empty if the branch doesn't exist in the repository
+   * @throws IOException Cannot access the underlying storage
+   */
+  Optional<CodeReviewCommit> getTip(BranchNameKey branch, OpenRepo repo) throws IOException {
+    CodeReviewCommit currentCommit;
+    if (branchTips.containsKey(branch)) {
+      currentCommit = branchTips.get(branch);
+    } else {
+      Ref r = repo.repo.exactRef(branch.branch());
+      if (r == null) {
+        return Optional.empty();
+      }
+      currentCommit = repo.rw.parseCommit(r.getObjectId());
+      branchTips.put(branch, currentCommit);
+    }
+
+    return Optional.of(currentCommit);
+  }
+
+  void put(BranchNameKey branch, CodeReviewCommit c) {
+    branchTips.put(branch, c);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/CircularPathFinder.java b/java/com/google/gerrit/server/submit/CircularPathFinder.java
new file mode 100644
index 0000000..d1920da
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/CircularPathFinder.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+class CircularPathFinder {
+  private CircularPathFinder() {}
+
+  /**
+   * Prints a circular path according to the nodes in {@code p} and the start node {@code target}.
+   */
+  public static <T> String printCircularPath(Collection<T> p, T target) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(target);
+    ArrayList<T> reverseP = new ArrayList<>(p);
+    Collections.reverse(reverseP);
+    for (T t : reverseP) {
+      sb.append("->");
+      sb.append(t);
+      if (t.equals(target)) {
+        break;
+      }
+    }
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index c94d49e..c433ee6 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
@@ -38,20 +40,23 @@
   interface Factory {
     EmailMerge create(
         Project.NameKey project,
-        Change.Id changeId,
+        Change change,
         Account.Id submitter,
-        NotifyResolver.Result notify);
+        NotifyResolver.Result notify,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
   private final ThreadLocalRequestContext requestContext;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final Project.NameKey project;
-  private final Change.Id changeId;
+  private final Change change;
   private final Account.Id submitter;
   private final NotifyResolver.Result notify;
+  private final RepoView repoView;
 
   @Inject
   EmailMerge(
@@ -59,18 +64,22 @@
       MergedSender.Factory mergedSenderFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
+      @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
-      @Assisted NotifyResolver.Result notify) {
+      @Assisted NotifyResolver.Result notify,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
     this.identifiedUserFactory = identifiedUserFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.project = project;
-    this.changeId = changeId;
+    this.change = change;
     this.submitter = submitter;
     this.notify = notify;
+    this.repoView = repoView;
   }
 
   void sendAsync() {
@@ -82,14 +91,15 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender cm = mergedSenderFactory.create(project, changeId);
+      MergedSender cm = mergedSenderFactory.create(project, change.getId());
       if (submitter != null) {
         cm.setFrom(submitter);
       }
       cm.setNotify(notify);
+      cm.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
       cm.send();
     } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", changeId);
+      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
       requestContext.setContext(old);
     }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index a4141be..5558c74 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -398,7 +399,7 @@
     Optional<Account> account =
         args.accountCache.get(submitter.accountId()).map(AccountState::account);
     if (account.isPresent() && account.get().fullName() != null) {
-      return " by " + account.get().fullName();
+      return " by " + ChangeNoteUtil.getAccountIdAsUsername(account.get().id());
     }
     return "";
   }
@@ -500,7 +501,12 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(ctx.getProject(), getId(), submitter.accountId(), ctx.getNotify(getId()))
+          .create(
+              ctx.getProject(),
+              toMerge.change(),
+              submitter.accountId(),
+              ctx.getNotify(getId()),
+              ctx.getRepoView())
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
@@ -549,7 +555,7 @@
 
     // Modify the commit with gitlink update
     try {
-      return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
+      return args.submoduleOp.amendGitlinksCommit(args.destBranch, commit);
     } catch (IOException e) {
       throw new StorageException(
           String.format("cannot update gitlink for the commit at branch %s", args.destBranch), e);
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index b48076194..5adda2c 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,19 +14,14 @@
 
 package com.google.gerrit.server.submit;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -46,18 +41,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.commons.lang.StringUtils;
 import org.eclipse.jgit.dircache.DirCache;
@@ -71,10 +61,8 @@
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
 
 public class SubmoduleOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -82,9 +70,11 @@
   /** Only used for branches without code review changes */
   public class GitlinkOp implements RepoOnlyOp {
     private final BranchNameKey branch;
+    private final BranchTips currentBranchTips;
 
-    GitlinkOp(BranchNameKey branch) {
+    GitlinkOp(BranchNameKey branch, BranchTips branchTips) {
       this.branch = branch;
+      this.currentBranchTips = branchTips;
     }
 
     @Override
@@ -92,7 +82,7 @@
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
         ctx.addRefUpdate(c.getParent(0), c, branch.branch());
-        addBranchTip(branch, c);
+        currentBranchTips.put(branch, c);
       }
     }
   }
@@ -123,42 +113,15 @@
     }
   }
 
-  private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
-  private final ProjectCache projectCache;
   private final VerboseSuperprojectUpdate verboseSuperProject;
-  private final boolean enableSuperProjectSubscriptions;
   private final long maxCombinedCommitMessageSize;
   private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
-  private final Map<BranchNameKey, GitModules> branchGitModules;
+  private final SubscriptionGraph.Factory subscriptionGraphFactory;
+  private final SubscriptionGraph subscriptionGraph;
 
-  /** Branches updated as part of the enclosing submit or push batch. */
-  private final ImmutableSet<BranchNameKey> updatedBranches;
-
-  /**
-   * Current branch tips, taking into account commits created during the submit process as well as
-   * submodule updates produced by this class.
-   */
-  private final Map<BranchNameKey, CodeReviewCommit> branchTips;
-
-  /**
-   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
-   * which are subscribed to by some superproject.
-   */
-  private final Set<BranchNameKey> affectedBranches;
-
-  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
-  private final ImmutableSet<BranchNameKey> sortedBranches;
-
-  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
-  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
-
-  /**
-   * Multimap of superproject name to all branch names within that superproject which have submodule
-   * subscriptions.
-   */
-  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+  private final BranchTips branchTips = new BranchTips();
 
   private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
@@ -168,233 +131,27 @@
       Set<BranchNameKey> updatedBranches,
       MergeOpRepoManager orm)
       throws SubmoduleConflictException {
-    this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
-    this.projectCache = projectCache;
     this.verboseSuperProject =
         cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
-    this.enableSuperProjectSubscriptions =
-        cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true);
     this.maxCombinedCommitMessageSize =
         cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
     this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
-    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
-    this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.affectedBranches = new HashSet<>();
-    this.branchTips = new HashMap<>();
-    this.branchGitModules = new HashMap<>();
-    this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.sortedBranches = calculateSubscriptionMaps();
-  }
-
-  /**
-   * Calculate the internal maps used by the operation.
-   *
-   * <p>In addition to the return value, the following fields are populated as a side effect:
-   *
-   * <ul>
-   *   <li>{@link #affectedBranches}
-   *   <li>{@link #targets}
-   *   <li>{@link #branchesByProject}
-   * </ul>
-   *
-   * @return the ordered set to be stored in {@link #sortedBranches}.
-   */
-  // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
-  // mutable maps, which makes this whole class difficult to understand.
-  //
-  // A cleaner architecture for this process might be:
-  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
-  //      structure representing the subscription graph, using a separate class with a properly-
-  //      documented interface.
-  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
-  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
-  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
-  //      relevant updates.
-  //
-  // In addition to improving readability, this approach has the advantage of making (1) and (2)
-  // testable using small tests.
-  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps()
-      throws SubmoduleConflictException {
-    if (!enableSuperProjectSubscriptions) {
+    this.subscriptionGraphFactory =
+        new SubscriptionGraph.DefaultFactory(gitmodulesFactory, projectCache, orm);
+    if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
+      this.subscriptionGraph = subscriptionGraphFactory.compute(updatedBranches);
+    } else {
       logger.atFine().log("Updating superprojects disabled");
-      return null;
+      this.subscriptionGraph =
+          SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
     }
-
-    logger.atFine().log("Calculating superprojects - submodules map");
-    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
-    for (BranchNameKey updatedBranch : updatedBranches) {
-      if (allVisited.contains(updatedBranch)) {
-        continue;
-      }
-
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
-    }
-
-    // Since the searchForSuperprojects will add all branches (related or
-    // unrelated) and ensure the superproject's branches get added first before
-    // a submodule branch. Need remove all unrelated branches and reverse
-    // the order.
-    allVisited.retainAll(affectedBranches);
-    reverse(allVisited);
-    return ImmutableSet.copyOf(allVisited);
-  }
-
-  private void searchForSuperprojects(
-      BranchNameKey current,
-      LinkedHashSet<BranchNameKey> currentVisited,
-      LinkedHashSet<BranchNameKey> allVisited)
-      throws SubmoduleConflictException {
-    logger.atFine().log("Now processing %s", current);
-
-    if (currentVisited.contains(current)) {
-      throw new SubmoduleConflictException(
-          "Branch level circular subscriptions detected:  "
-              + printCircularPath(currentVisited, current));
-    }
-
-    if (allVisited.contains(current)) {
-      return;
-    }
-
-    currentVisited.add(current);
-    try {
-      Collection<SubmoduleSubscription> subscriptions =
-          superProjectSubscriptionsForSubmoduleBranch(current);
-      for (SubmoduleSubscription sub : subscriptions) {
-        BranchNameKey superBranch = sub.getSuperProject();
-        searchForSuperprojects(superBranch, currentVisited, allVisited);
-        targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.project(), superBranch);
-        affectedBranches.add(superBranch);
-        affectedBranches.add(sub.getSubmodule());
-      }
-    } catch (IOException e) {
-      throw new StorageException("Cannot find superprojects for " + current, e);
-    }
-    currentVisited.remove(current);
-    allVisited.add(current);
-  }
-
-  private static <T> void reverse(LinkedHashSet<T> set) {
-    if (set == null) {
-      return;
-    }
-
-    Deque<T> q = new ArrayDeque<>(set);
-    set.clear();
-
-    while (!q.isEmpty()) {
-      set.add(q.removeLast());
-    }
-  }
-
-  private <T> String printCircularPath(LinkedHashSet<T> p, T target) {
-    StringBuilder sb = new StringBuilder();
-    sb.append(target);
-    ArrayList<T> reverseP = new ArrayList<>(p);
-    Collections.reverse(reverseP);
-    for (T t : reverseP) {
-      sb.append("->");
-      sb.append(t);
-      if (t.equals(target)) {
-        break;
-      }
-    }
-    return sb.toString();
-  }
-
-  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
-      throws IOException {
-    Collection<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", s);
-    for (RefSpec r : s.getMatchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      if (r.isWildcard()) {
-        // refs/heads/*[:refs/somewhere/*]
-        ret.add(
-            BranchNameKey.create(
-                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
-      } else {
-        // e.g. refs/heads/master[:refs/heads/stable]
-        String dest = r.getDestination();
-        if (dest == null) {
-          dest = r.getSource();
-        }
-        ret.add(BranchNameKey.create(s.getProject(), dest));
-      }
-    }
-
-    for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      OpenRepo or;
-      try {
-        or = orm.getRepo(s.getProject());
-      } catch (NoSuchProjectException e) {
-        // A project listed a non existent project to be allowed
-        // to subscribe to it. Allow this for now, i.e. no exception is
-        // thrown.
-        continue;
-      }
-
-      for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
-        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
-          continue;
-        }
-        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
-        if (!ret.contains(b)) {
-          ret.add(b);
-        }
-      }
-    }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
-    return ret;
   }
 
   @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
-  public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
-      BranchNameKey srcBranch) throws IOException {
-    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
-    Collection<SubmoduleSubscription> ret = new ArrayList<>();
-    Project.NameKey srcProject = srcBranch.project();
-    for (SubscribeSection s :
-        projectCache
-            .get(srcProject)
-            .orElseThrow(illegalState(srcProject))
-            .getSubscribeSections(srcBranch)) {
-      logger.atFine().log("Checking subscribe section %s", s);
-      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
-      for (BranchNameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.project();
-        try {
-          OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.branch());
-          if (id == null) {
-            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
-            continue;
-          }
-        } catch (NoSuchProjectException e) {
-          logger.atFine().log("The project %s doesn't exist", targetProject);
-          continue;
-        }
-
-        GitModules m = branchGitModules.get(targetBranch);
-        if (m == null) {
-          m = gitmodulesFactory.create(targetBranch, orm);
-          branchGitModules.put(targetBranch, m);
-        }
-        ret.addAll(m.subscribedTo(srcBranch));
-      }
-    }
-    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
-    return ret;
+  public boolean hasSuperproject(BranchNameKey branch) {
+    return subscriptionGraph.hasSuperproject(branch);
   }
 
   public void updateSuperProjects() throws RestApiException {
@@ -407,11 +164,11 @@
     try {
       for (Project.NameKey project : projects) {
         // only need superprojects
-        if (branchesByProject.containsKey(project)) {
+        if (subscriptionGraph.isAffectedSuperProject(project)) {
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (BranchNameKey branch : branchesByProject.get(project)) {
+          for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
@@ -432,18 +189,13 @@
       throw new StorageException("Cannot access superproject", e);
     }
 
-    CodeReviewCommit currentCommit;
-    if (branchTips.containsKey(subscriber)) {
-      currentCommit = branchTips.get(subscriber);
-    } else {
-      Ref r = or.repo.exactRef(subscriber.branch());
-      if (r == null) {
-        throw new SubmoduleConflictException(
-            "The branch was probably deleted from the subscriber repository");
-      }
-      currentCommit = or.rw.parseCommit(r.getObjectId());
-      addBranchTip(subscriber, currentCommit);
-    }
+    CodeReviewCommit currentCommit =
+        branchTips
+            .getTip(subscriber, or)
+            .orElseThrow(
+                () ->
+                    new SubmoduleConflictException(
+                        "The branch was probably deleted from the subscriber repository"));
 
     StringBuilder msgbuf = new StringBuilder();
     PersonIdent author = null;
@@ -452,7 +204,7 @@
     int count = 0;
 
     List<SubmoduleSubscription> subscriptions =
-        targets.get(subscriber).stream()
+        subscriptionGraph.getSubscriptions(subscriber).stream()
             .sorted(comparing(SubmoduleSubscription::getPath))
             .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
@@ -493,7 +245,7 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit amendGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleConflictException {
     OpenRepo or;
     try {
@@ -505,7 +257,7 @@
     StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
-    for (SubmoduleSubscription s : targets.get(subscriber)) {
+    for (SubmoduleSubscription s : subscriptionGraph.getSubscriptions(subscriber)) {
       updateSubmodule(dc, ed, msgbuf, s);
     }
     ed.finish();
@@ -574,25 +326,15 @@
       }
     }
 
-    final CodeReviewCommit newCommit;
-    if (branchTips.containsKey(s.getSubmodule())) {
-      // This submodule's branch was updated as part of this specific submit batch: update the
-      // gitlink to point to the new commit from the batch.
-      newCommit = branchTips.get(s.getSubmodule());
-    } else {
-      // For whatever reason, this submodule was not updated as part of this submit batch, but the
-      // superproject is still subscribed to this branch. Re-read the ref to see if anything has
-      // changed since the last time the gitlink was updated, and roll that update into the same
-      // commit as all other submodule updates.
-      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().branch());
-      if (ref == null) {
-        ed.add(new DeletePath(s.getPath()));
-        return null;
-      }
-      newCommit = subOr.rw.parseCommit(ref.getObjectId());
-      addBranchTip(s.getSubmodule(), newCommit);
+    Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
+    if (!maybeNewCommit.isPresent()) {
+      // This submodule branch is neither in the submit set nor in the repository itself
+      ed.add(new DeletePath(s.getPath()));
+      return null;
     }
 
+    CodeReviewCommit newCommit = maybeNewCommit.get();
+
     if (Objects.equals(newCommit, oldCommit)) {
       // gitlink have already been updated for this submodule
       return null;
@@ -678,11 +420,11 @@
 
   ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
-    for (Project.NameKey project : branchesByProject.keySet()) {
+    for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
-    for (BranchNameKey branch : updatedBranches) {
+    for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
       projects.add(branch.project());
     }
     return ImmutableSet.copyOf(projects);
@@ -695,7 +437,8 @@
       throws SubmoduleConflictException {
     if (current.contains(project)) {
       throw new SubmoduleConflictException(
-          "Project level circular subscriptions detected:  " + printCircularPath(current, project));
+          "Project level circular subscriptions detected:  "
+              + CircularPathFinder.printCircularPath(current, project));
     }
 
     if (projects.contains(project)) {
@@ -704,8 +447,8 @@
 
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
-    for (BranchNameKey branch : branchesByProject.get(project)) {
-      Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
+    for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
       for (SubmoduleSubscription s : subscriptions) {
         subprojects.add(s.getSubmodule().project());
       }
@@ -721,15 +464,13 @@
 
   ImmutableSet<BranchNameKey> getBranchesInOrder() {
     LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
-    if (sortedBranches != null) {
-      branches.addAll(sortedBranches);
-    }
-    branches.addAll(updatedBranches);
+    branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
+    branches.addAll(subscriptionGraph.getUpdatedBranches());
     return ImmutableSet.copyOf(branches);
   }
 
   boolean hasSubscription(BranchNameKey branch) {
-    return targets.containsKey(branch);
+    return subscriptionGraph.hasSubscription(branch);
   }
 
   void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
@@ -737,6 +478,6 @@
   }
 
   void addOp(BatchUpdate bu, BranchNameKey branch) {
-    bu.addRepoOnlyOp(new GitlinkOp(branch));
+    bu.addRepoOnlyOp(new GitlinkOp(branch, branchTips));
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
new file mode 100644
index 0000000..406d878
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -0,0 +1,375 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.RefSpec;
+
+/**
+ * A container which stores subscription relationship. A SubscriptionGraph is calculated every time
+ * changes are pushed. Some branches are updated in these changes, and if these branches are
+ * subscribed by other projects, SubscriptionGraph would record information about these updated
+ * branches and branches/projects affected.
+ */
+public class SubscriptionGraph {
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<BranchNameKey> updatedBranches;
+
+  /**
+   * All branches affected, including those in superprojects and submodules, sorted by submodule
+   * traversal order. To support nested subscriptions, GitLink commits need to be updated in order.
+   * The closer to topological "leaf", the earlier a commit should be updated.
+   *
+   * <p>For example, there are three projects, top level project p1 subscribed to p2, p2 subscribed
+   * to bottom level project p3. When submit a change for p3. We need update both p2 and p1. To be
+   * more precise, we need update p2 first and then update p1.
+   */
+  private final ImmutableSet<BranchNameKey> sortedBranches;
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
+  private final ImmutableSetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
+  private final ImmutableSetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+  /** All branches subscribed by other projects. */
+  private final ImmutableSet<BranchNameKey> subscribedBranches;
+
+  public SubscriptionGraph(
+      Set<BranchNameKey> updatedBranches,
+      SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+      SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+      Set<BranchNameKey> subscribedBranches,
+      Set<BranchNameKey> sortedBranches) {
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
+    this.targets = ImmutableSetMultimap.copyOf(targets);
+    this.branchesByProject = ImmutableSetMultimap.copyOf(branchesByProject);
+    this.subscribedBranches = ImmutableSet.copyOf(subscribedBranches);
+    this.sortedBranches = ImmutableSet.copyOf(sortedBranches);
+  }
+
+  /** Returns an empty {@code SubscriptionGraph}. */
+  static SubscriptionGraph createEmptyGraph(Set<BranchNameKey> updatedBranches) {
+    return new SubscriptionGraph(
+        updatedBranches,
+        ImmutableSetMultimap.of(),
+        ImmutableSetMultimap.of(),
+        ImmutableSet.of(),
+        ImmutableSet.of());
+  }
+
+  /** Get branches updated as part of the enclosing submit or push batch. */
+  ImmutableSet<BranchNameKey> getUpdatedBranches() {
+    return updatedBranches;
+  }
+
+  /** Get all superprojects affected. */
+  ImmutableSet<Project.NameKey> getAffectedSuperProjects() {
+    return branchesByProject.keySet();
+  }
+
+  /** See if a {@code project} is a superproject affected. */
+  boolean isAffectedSuperProject(Project.NameKey project) {
+    return branchesByProject.containsKey(project);
+  }
+
+  /**
+   * Returns all branches within the superproject {@code project} which have submodule
+   * subscriptions.
+   */
+  ImmutableSet<BranchNameKey> getAffectedSuperBranches(Project.NameKey project) {
+    return branchesByProject.get(project);
+  }
+
+  /**
+   * Get all affected branches, including the submodule branches and superproject branches, sorted
+   * by traversal order.
+   *
+   * @see SubscriptionGraph#sortedBranches
+   */
+  ImmutableSet<BranchNameKey> getSortedSuperprojectAndSubmoduleBranches() {
+    return sortedBranches;
+  }
+
+  /** Check if a {@code branch} is a submodule of a superproject. */
+  boolean hasSuperproject(BranchNameKey branch) {
+    return subscribedBranches.contains(branch);
+  }
+
+  /** See if a {@code branch} is a superproject branch affected. */
+  boolean hasSubscription(BranchNameKey branch) {
+    return targets.containsKey(branch);
+  }
+
+  /** Get all related {@code SubmoduleSubscription}s whose super branch is {@code branch}. */
+  ImmutableSet<SubmoduleSubscription> getSubscriptions(BranchNameKey branch) {
+    return targets.get(branch);
+  }
+
+  public interface Factory {
+    SubscriptionGraph compute(Set<BranchNameKey> updatedBranches) throws SubmoduleConflictException;
+  }
+
+  static class DefaultFactory implements Factory {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+    private final ProjectCache projectCache;
+    private final GitModules.Factory gitmodulesFactory;
+    private final Map<BranchNameKey, GitModules> branchGitModules;
+    private final MergeOpRepoManager orm;
+
+    // Fields required to the constructor of SubscriptionGraph.
+    /** All affected branches, including those in superprojects and submodules. */
+    private final Set<BranchNameKey> affectedBranches;
+
+    /** @see SubscriptionGraph#targets */
+    private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+    /** @see SubscriptionGraph#branchesByProject */
+    private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+    /** @see SubscriptionGraph#subscribedBranches */
+    private final Set<BranchNameKey> subscribedBranches;
+
+    DefaultFactory(
+        GitModules.Factory gitmodulesFactory, ProjectCache projectCache, MergeOpRepoManager orm) {
+      this.gitmodulesFactory = gitmodulesFactory;
+      this.projectCache = projectCache;
+      this.orm = orm;
+      this.branchGitModules = new HashMap<>();
+
+      this.affectedBranches = new HashSet<>();
+      this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
+      this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
+      this.subscribedBranches = new HashSet<>();
+    }
+
+    @Override
+    public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches)
+        throws SubmoduleConflictException {
+      return new SubscriptionGraph(
+          updatedBranches,
+          targets,
+          branchesByProject,
+          subscribedBranches,
+          calculateSubscriptionMaps(updatedBranches));
+    }
+
+    /**
+     * Calculate the internal maps used by the operation.
+     *
+     * <p>In addition to the return value, the following fields are populated as a side effect:
+     *
+     * <ul>
+     *   <li>{@link #affectedBranches}
+     *   <li>{@link #targets}
+     *   <li>{@link #branchesByProject}
+     *   <li>{@link #subscribedBranches}
+     * </ul>
+     *
+     * @return the ordered set to be stored in {@link #sortedBranches}.
+     */
+    private Set<BranchNameKey> calculateSubscriptionMaps(Set<BranchNameKey> updatedBranches)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Calculating superprojects - submodules map");
+      LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+      for (BranchNameKey updatedBranch : updatedBranches) {
+        if (allVisited.contains(updatedBranch)) {
+          continue;
+        }
+
+        searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
+      }
+
+      // Since the searchForSuperprojects will add all branches (related or
+      // unrelated) and ensure the superproject's branches get added first before
+      // a submodule branch. Need remove all unrelated branches and reverse
+      // the order.
+      allVisited.retainAll(affectedBranches);
+      reverse(allVisited);
+      return allVisited;
+    }
+
+    private void searchForSuperprojects(
+        BranchNameKey current,
+        LinkedHashSet<BranchNameKey> currentVisited,
+        LinkedHashSet<BranchNameKey> allVisited)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Now processing %s", current);
+
+      if (currentVisited.contains(current)) {
+        throw new SubmoduleConflictException(
+            "Branch level circular subscriptions detected:  "
+                + CircularPathFinder.printCircularPath(currentVisited, current));
+      }
+
+      if (allVisited.contains(current)) {
+        return;
+      }
+
+      currentVisited.add(current);
+      try {
+        Collection<SubmoduleSubscription> subscriptions =
+            superProjectSubscriptionsForSubmoduleBranch(current);
+        for (SubmoduleSubscription sub : subscriptions) {
+          BranchNameKey superBranch = sub.getSuperProject();
+          searchForSuperprojects(superBranch, currentVisited, allVisited);
+          targets.put(superBranch, sub);
+          branchesByProject.put(superBranch.project(), superBranch);
+          affectedBranches.add(superBranch);
+          affectedBranches.add(sub.getSubmodule());
+          subscribedBranches.add(sub.getSubmodule());
+        }
+      } catch (IOException e) {
+        throw new StorageException("Cannot find superprojects for " + current, e);
+      }
+      currentVisited.remove(current);
+      allVisited.add(current);
+    }
+
+    private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
+        throws IOException {
+      Collection<BranchNameKey> ret = new HashSet<>();
+      logger.atFine().log("Inspecting SubscribeSection %s", s);
+      for (RefSpec r : s.getMatchingRefSpecs()) {
+        logger.atFine().log("Inspecting [matching] ref %s", r);
+        if (!r.matchSource(src.branch())) {
+          continue;
+        }
+        if (r.isWildcard()) {
+          // refs/heads/*[:refs/somewhere/*]
+          ret.add(
+              BranchNameKey.create(
+                  s.getProject(), r.expandFromSource(src.branch()).getDestination()));
+        } else {
+          // e.g. refs/heads/master[:refs/heads/stable]
+          String dest = r.getDestination();
+          if (dest == null) {
+            dest = r.getSource();
+          }
+          ret.add(BranchNameKey.create(s.getProject(), dest));
+        }
+      }
+
+      for (RefSpec r : s.getMultiMatchRefSpecs()) {
+        logger.atFine().log("Inspecting [all] ref %s", r);
+        if (!r.matchSource(src.branch())) {
+          continue;
+        }
+        OpenRepo or;
+        try {
+          or = orm.getRepo(s.getProject());
+        } catch (NoSuchProjectException e) {
+          // A project listed a non existent project to be allowed
+          // to subscribe to it. Allow this for now, i.e. no exception is
+          // thrown.
+          continue;
+        }
+
+        for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
+          if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
+            continue;
+          }
+          BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
+          if (!ret.contains(b)) {
+            ret.add(b);
+          }
+        }
+      }
+      logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
+      return ret;
+    }
+
+    private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
+        BranchNameKey srcBranch) throws IOException {
+      logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
+      Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      Project.NameKey srcProject = srcBranch.project();
+      for (SubscribeSection s :
+          projectCache
+              .get(srcProject)
+              .orElseThrow(illegalState(srcProject))
+              .getSubscribeSections(srcBranch)) {
+        logger.atFine().log("Checking subscribe section %s", s);
+        Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
+        for (BranchNameKey targetBranch : branches) {
+          Project.NameKey targetProject = targetBranch.project();
+          try {
+            OpenRepo or = orm.getRepo(targetProject);
+            ObjectId id = or.repo.resolve(targetBranch.branch());
+            if (id == null) {
+              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              continue;
+            }
+          } catch (NoSuchProjectException e) {
+            logger.atFine().log("The project %s doesn't exist", targetProject);
+            continue;
+          }
+
+          GitModules m = branchGitModules.get(targetBranch);
+          if (m == null) {
+            m = gitmodulesFactory.create(targetBranch, orm);
+            branchGitModules.put(targetBranch, m);
+          }
+          ret.addAll(m.subscribedTo(srcBranch));
+        }
+      }
+      logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
+      return ret;
+    }
+
+    private static <T> void reverse(LinkedHashSet<T> set) {
+      if (set == null) {
+        return;
+      }
+
+      Deque<T> q = new ArrayDeque<>(set);
+      set.clear();
+
+      while (!q.isEmpty()) {
+        set.add(q.removeLast());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java b/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
index bc52308..9fdc9e6 100644
--- a/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
+++ b/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
@@ -26,6 +26,9 @@
   /**
    * Called when an account should be activated to allow validation of the account activation.
    *
+   * <p>See {@link com.google.gerrit.extensions.events.AccountActivationListener} for a listener
+   * that's run after the account got activated.
+   *
    * @param account the account that should be activated
    * @throws ValidationException if validation fails
    */
@@ -34,6 +37,9 @@
   /**
    * Called when an account should be deactivated to allow validation of the account deactivation.
    *
+   * <p>See {@link com.google.gerrit.extensions.events.AccountActivationListener} for a listener
+   * that's run after the account got deactivated.
+   *
    * @param account the account that should be deactivated
    * @throws ValidationException if validation fails
    */
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 916775d..6997d96 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.FileUtil;
@@ -38,6 +39,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Locale;
 import java.util.Set;
 import org.apache.sshd.common.SshException;
@@ -197,9 +199,15 @@
             continue;
           }
 
+          List<String> parts = Splitter.on(' ').splitToList(line);
+          if (parts.size() > 2) {
+            throw new IllegalArgumentException(
+                "Invalid peer key file format, only <key [comment]> lines supported");
+          }
           try {
             byte[] bin =
-                BaseEncoding.base64().decode(new String(line.getBytes(ISO_8859_1), ISO_8859_1));
+                BaseEncoding.base64()
+                    .decode(new String(parts.get(0).getBytes(ISO_8859_1), ISO_8859_1));
             keys.add(new ByteArrayBuffer(bin).getRawPublicKey());
           } catch (RuntimeException | SshException e) {
             logBadKey(path, line, e);
diff --git a/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java b/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java
new file mode 100644
index 0000000..1086626
--- /dev/null
+++ b/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.AccountActivationListener;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.sshd.BaseCommand.Failure;
+import com.google.inject.Inject;
+import java.io.IOException;
+
+/** Closes open SSH connections upon account deactivation. */
+public class InactiveAccountDisconnector implements AccountActivationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final SshDaemon sshDaemon;
+
+  @Inject
+  InactiveAccountDisconnector(SshDaemon sshDaemon) {
+    this.sshDaemon = sshDaemon;
+  }
+
+  @Override
+  public void onAccountDeactivated(int id) {
+    try {
+      SshUtil.forEachSshSession(
+          sshDaemon,
+          (sshId, sshSession, abstractSession, ioSession) -> {
+            CurrentUser sessionUser = sshSession.getUser();
+            if (sessionUser.isIdentifiedUser() && sessionUser.getAccountId().get() == id) {
+              logger.atInfo().log(
+                  "Disconnecting SSH session %s because user %s(%d) got deactivated",
+                  abstractSession, sessionUser.getLoggableName(), id);
+              try {
+                abstractSession.disconnect(-1, "user deactivated");
+              } catch (IOException e) {
+                logger.atWarning().withCause(e).log(
+                    "Failure while deactivating session %s", abstractSession);
+              }
+            }
+          });
+    } catch (Failure e) {
+      // Ssh Daemon no longer running. Since we're only disconnecting connections anyways, this is
+      // most likely ok, so we log only at info level.
+      logger.atInfo().withCause(e).log("Failure while disconnecting deactivated account %d", id);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index e4aa14c..9301f8a 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleModule;
@@ -103,6 +104,9 @@
     DynamicItem.itemOf(binder(), SshCreateCommandInterceptor.class);
     DynamicSet.setOf(binder(), SshExecuteCommandInterceptor.class);
 
+    DynamicSet.bind(binder(), AccountActivationListener.class)
+        .to(InactiveAccountDisconnector.class);
+
     listener().toInstance(registerInParentInjectors());
     listener().to(SshLog.class);
     listener().to(SshDaemon.class);
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index eac9737..abbd81d 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.sshd.BaseCommand.Failure;
 import com.google.gerrit.sshd.SshScope.Context;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -30,7 +31,10 @@
 import java.security.interfaces.RSAPublicKey;
 import java.security.spec.InvalidKeySpecException;
 import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.io.IoAcceptor;
+import org.apache.sshd.common.io.IoSession;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.server.session.ServerSession;
 
@@ -152,4 +156,30 @@
       final Account.Id account) {
     return userFactory.create(sd.getRemoteAddress(), account);
   }
+
+  public static void forEachSshSession(SshDaemon sshDaemon, SessionConsumer consumer)
+      throws Failure {
+    IoAcceptor ioAcceptor = sshDaemon.getIoAcceptor();
+    if (ioAcceptor == null) {
+      throw new Failure(1, "fatal: sshd no longer running");
+    }
+    ioAcceptor
+        .getManagedSessions()
+        .forEach(
+            (id, ioSession) -> {
+              AbstractSession abstractSession = AbstractSession.getSession(ioSession, true);
+              if (abstractSession != null) {
+                SshSession sshSession = abstractSession.getAttribute(SshSession.KEY);
+                if (sshSession != null) {
+                  consumer.accept(id, sshSession, abstractSession, ioSession);
+                }
+              }
+            });
+  }
+
+  @FunctionalInterface
+  public interface SessionConsumer {
+    public void accept(
+        Long id, SshSession sshSession, AbstractSession abstractSession, IoSession ioSession);
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 60a878a..093f647 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -23,15 +23,12 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
-import com.google.gerrit.sshd.SshSession;
+import com.google.gerrit.sshd.SshUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import org.apache.sshd.common.future.CloseFuture;
-import org.apache.sshd.common.io.IoAcceptor;
-import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
@@ -60,36 +57,26 @@
 
   @Override
   protected void run() throws Failure {
-    IoAcceptor acceptor = sshDaemon.getIoAcceptor();
-    if (acceptor == null) {
-      throw new Failure(1, "fatal: sshd no longer running");
-    }
-    for (String sessionId : sessionIds) {
-      boolean connectionFound = false;
-      int id = (int) Long.parseLong(sessionId, 16);
-      for (IoSession io : acceptor.getManagedSessions().values()) {
-        AbstractSession serverSession = AbstractSession.getSession(io, true);
-        SshSession sshSession =
-            serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
-        if (sshSession != null && sshSession.getSessionId() == id) {
-          connectionFound = true;
-          stdout.println("closing connection " + sessionId + "...");
-          CloseFuture future = io.close(true);
-          if (wait) {
-            try {
-              future.await();
-              stdout.println("closed connection " + sessionId);
-            } catch (IOException e) {
-              logger.atWarning().log(
-                  "Wait for connection to close interrupted: %s", e.getMessage());
+    SshUtil.forEachSshSession(
+        sshDaemon,
+        (k, sshSession, abstractSession, ioSession) -> {
+          String sessionId = String.format("%08x", sshSession.getSessionId());
+          if (sessionIds.remove(sessionId)) {
+            stdout.println("closing connection " + sessionId + "...");
+            CloseFuture future = ioSession.close(true);
+            if (wait) {
+              try {
+                future.await();
+                stdout.println("closed connection " + sessionId);
+              } catch (IOException e) {
+                logger.atWarning().log(
+                    "Wait for connection to close interrupted: %s", e.getMessage());
+              }
             }
           }
-          break;
-        }
-      }
-      if (!connectionFound) {
-        stderr.print("close connection " + sessionId + ": no such connection\n");
-      }
+        });
+    for (String sessionId : sessionIds) {
+      stderr.print("close connection " + sessionId + ": no such connection\n");
     }
   }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 8800463..a682d33 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.testing;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
-import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
 import com.google.gerrit.extensions.client.AuthType;
@@ -30,6 +31,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -209,7 +211,7 @@
           @Singleton
           @DiffExecutor
           public ExecutorService createDiffExecutor() {
-            return MoreExecutors.newDirectExecutorService();
+            return newDirectExecutorService();
           }
         });
     install(new DefaultMemoryCacheModule());
@@ -277,7 +279,7 @@
   @Singleton
   @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
-    return MoreExecutors.newDirectExecutorService();
+    return newDirectExecutorService();
   }
 
   @Provides
@@ -287,6 +289,13 @@
     return queues.createQueue(2, "FanOut");
   }
 
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService createCacheRefreshExecutor() {
+    return newDirectExecutorService();
+  }
+
   private Module luceneIndexModule() {
     return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
   }
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
new file mode 100644
index 0000000..8e08b1c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.testing.FakeEmailSender;
+import java.net.URL;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class OutgoingEmailIT extends AbstractDaemonTest {
+
+  @Test
+  public void messageIdHeaderFromChangeUpdate() throws Exception {
+    Repository repository = repoManager.openRepository(project);
+    PushOneCommit.Result result = createChange();
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).abandon();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).restore();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "auth.registerEmailPrivateKey",
+      value = "HsOc6l_2lhS9G7sE_RsnS7Z6GJjdRDX14co=")
+  public void messageIdHeaderFromAccountUpdate() throws Exception {
+    Repository allUsersRepo = repoManager.openRepository(allUsers);
+    String email = "new.email@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    sender.clear();
+    gApi.accounts().self().addEmail(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(Address.create(email));
+
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                allUsersRepo
+                        .getRefDatabase()
+                        .exactRef(RefNames.refsUsers(admin.id()))
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+  }
+
+  @Test
+  public void messageIdHeaderFromPasswordUpdate() throws Exception {
+    sender.clear();
+    String newPassword = gApi.accounts().self().generateHttpPassword();
+    assertThat(newPassword).isNotNull();
+    assertThat(getMessageId(sender))
+        .containsMatch("<HTTP_password_change-" + admin.id().toString() + ".*@.*>");
+  }
+
+  @Test
+  public void htmlAndPlainTextSuffixAddedToMessageId() throws Exception {
+    PushOneCommit.Result result = createChange();
+    GeneralPreferencesInfo generalPreferencesInfo = new GeneralPreferencesInfo();
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    assertThat(getMessageId(sender)).contains("-PLAIN");
+    sender.clear();
+
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.reject());
+    assertThat(getMessageId(sender)).contains("-HTML");
+  }
+
+  private static String getMessageId(FakeEmailSender sender) {
+    return ((EmailHeader.String)
+            (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
+        .getString();
+  }
+
+  // Each message-id must start with '<' and end with '>'. Also, it must contain no spaces and it
+  // must contain a '@'.
+  private String withPrefixAndSuffixForMessageId(String id) throws Exception {
+    return "<" + id + "@" + new URL(canonicalWebUrl.get()).getHost() + ">";
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index d5dd241..f9ba8a2 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -45,6 +45,10 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
@@ -84,6 +88,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
@@ -104,6 +109,7 @@
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -115,6 +121,7 @@
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ServerInitiated;
@@ -159,6 +166,14 @@
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
@@ -183,6 +198,7 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.treewalk.TreeWalk;
 import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
 public class AccountIT extends AbstractDaemonTest {
@@ -221,6 +237,9 @@
 
   @Inject protected GroupOperations groupOperations;
 
+  private BasicCookieStore httpCookieStore;
+  private CloseableHttpClient httpclient;
+
   @After
   public void clearPublicKeyStore() throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -247,6 +266,16 @@
     }
   }
 
+  @Before
+  public void createHttpClient() {
+    httpCookieStore = new BasicCookieStore();
+    httpclient =
+        HttpClientBuilder.create()
+            .disableRedirectHandling()
+            .setDefaultCookieStore(httpCookieStore)
+            .build();
+  }
+
   protected void assertLabelPermission(
       Project.NameKey project,
       GroupReference groupReference,
@@ -577,13 +606,50 @@
   }
 
   @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void activeUserGetSessionCookieOnLogin() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    assertThat(accountIdApi().getActive()).isTrue();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+  }
+
+  @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void inactiveUserDoesNotGetCookieOnLogin() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    accountIdApi().setActive(false);
+    assertThat(accountIdApi().getActive()).isFalse();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+  public void userDeactivatedAfterLoginDoesNotGetCookie() throws Exception {
+    Integer accountId = accountIdApi().get()._accountId;
+    assertThat(accountIdApi().getActive()).isTrue();
+
+    webLogin(accountId);
+    assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+    httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_OK);
+
+    accountIdApi().setActive(false);
+    assertThat(accountIdApi().getActive()).isFalse();
+
+    httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_FORBIDDEN);
+  }
+
+  @Test
   public void validateAccountActivation() throws Exception {
     Account.Id activatableAccountId =
         accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
     Account.Id deactivatableAccountId =
         accountOperations.newAccount().preferredEmail("foo@deactivatable.com").create();
 
-    AccountActivationValidationListener listener =
+    AccountActivationValidationListener validationListener =
         new AccountActivationValidationListener() {
           @Override
           public void validateActivation(AccountState account) throws ValidationException {
@@ -601,7 +667,11 @@
             }
           }
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+
+    AccountActivationListener listener = mock(AccountActivationListener.class);
+
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(validationListener).add(listener)) {
       /* Test account that can be activated, but not deactivated */
       // Deactivate account that is already inactive
       ResourceConflictException thrown =
@@ -610,14 +680,18 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
+      verifyZeroInteractions(listener);
 
       // Activate account that can be activated
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+      verify(listener).onAccountActivated(activatableAccountId.get());
+      verifyNoMoreInteractions(listener);
 
       // Activate account that is already active
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+      verifyZeroInteractions(listener);
 
       // Try deactivating account that cannot be deactivated
       thrown =
@@ -626,15 +700,19 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+      verifyZeroInteractions(listener);
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
       gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
+      verifyZeroInteractions(listener);
 
       // Deactivate account that can be deactivated
       gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+      verify(listener).onAccountDeactivated(deactivatableAccountId.get());
+      verifyNoMoreInteractions(listener);
 
       // Deactivate account that is already inactive
       thrown =
@@ -643,6 +721,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+      verifyZeroInteractions(listener);
 
       // Try activating account that cannot be activated
       thrown =
@@ -651,6 +730,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+      verifyZeroInteractions(listener);
     }
   }
 
@@ -2981,6 +3061,30 @@
     assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.id());
   }
 
+  private AccountApi accountIdApi() throws RestApiException {
+    return gApi.accounts().id(user.id().get());
+  }
+
+  private Set<String> getCookiesNames() {
+    Set<String> cookieNames =
+        httpCookieStore.getCookies().stream()
+            .map(cookie -> cookie.getName())
+            .collect(Collectors.toSet());
+    return cookieNames;
+  }
+
+  private void webLogin(Integer accountId) throws IOException, ClientProtocolException {
+    httpGetAndAssertStatus(
+        "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
+  }
+
+  private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus)
+      throws ClientProtocolException, IOException {
+    HttpGet httpGet = new HttpGet(canonicalWebUrl.get() + urlPath);
+    HttpResponse loginResponse = httpclient.execute(httpGet);
+    assertThat(loginResponse.getStatusLine().getStatusCode()).isEqualTo(expectedHttpStatus);
+  }
+
   private static class RefUpdateCounter implements GitReferenceUpdatedListener {
     private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
new file mode 100644
index 0000000..80eff96
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.events.AccountActivationListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.validators.AccountActivationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests the wiring of a real plugin's account listeners
+ *
+ * <p>This test really puts focus on the wiring of the account listeners. Tests for the inner
+ * workings of account activation/deactivation can be found in {@link AccountIT}.
+ */
+@TestPlugin(
+    name = "account-listener-it-plugin",
+    sysModule = "com.google.gerrit.acceptance.api.accounts.AccountListenersIT$Module")
+public class AccountListenersIT extends LightweightPluginDaemonTest {
+  @Inject private AccountOperations accountOperations;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), AccountActivationValidationListener.class).to(Validator.class);
+      DynamicSet.bind(binder(), AccountActivationListener.class).to(Listener.class);
+    }
+  }
+
+  Validator validator;
+  Listener listener;
+
+  @Before
+  public void setUp() {
+    validator = plugin.getSysInjector().getInstance(Validator.class);
+
+    listener = plugin.getSysInjector().getInstance(Listener.class);
+  }
+
+  @Test
+  public void testActivation() throws RestApiException {
+    int id = accountOperations.newAccount().inactive().create().get();
+
+    gApi.accounts().id(id).setActive(true);
+
+    validator.assertActivationValidation(id);
+    listener.assertActivated(id);
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isTrue();
+  }
+
+  @Test
+  public void testActivationProhibited() throws RestApiException {
+    int id = accountOperations.newAccount().inactive().create().get();
+
+    validator.failActivationValidations();
+
+    assertThrows(
+        ResourceConflictException.class,
+        () -> {
+          gApi.accounts().id(id).setActive(true);
+        });
+
+    validator.assertActivationValidation(id);
+    // No call to activation listener as validation failed
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isFalse();
+  }
+
+  @Test
+  public void testDeactivation() throws RestApiException {
+    int id = accountOperations.newAccount().active().create().get();
+
+    gApi.accounts().id(id).setActive(false);
+
+    validator.assertDeactivationValidation(id);
+    listener.assertDeactivated(id);
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isFalse();
+  }
+
+  @Test
+  public void testDeactivationProhibited() throws RestApiException {
+    int id = accountOperations.newAccount().active().create().get();
+
+    validator.failDeactivationValidations();
+
+    assertThrows(
+        ResourceConflictException.class,
+        () -> {
+          gApi.accounts().id(id).setActive(false);
+        });
+
+    validator.assertDeactivationValidation(id);
+    // No call to activation listener as validation failed
+    assertNoMoreEvents();
+    assertThat(gApi.accounts().id(id).getActive()).isTrue();
+  }
+
+  private void assertNoMoreEvents() {
+    validator.assertNoMoreEvents();
+    listener.assertNoMoreEvents();
+  }
+
+  @Singleton
+  public static class Validator implements AccountActivationValidationListener {
+    private Integer lastIdActivationValidation;
+    private Integer lastIdDeactivationValidation;
+    private boolean failActivationValidations;
+    private boolean failDeactivationValidations;
+
+    @Override
+    public void validateActivation(AccountState account) throws ValidationException {
+      assertThat(lastIdActivationValidation).isNull();
+      lastIdActivationValidation = account.account().id().get();
+      if (failActivationValidations) {
+        throw new ValidationException("testing validation failure");
+      }
+    }
+
+    @Override
+    public void validateDeactivation(AccountState account) throws ValidationException {
+      assertThat(lastIdDeactivationValidation).isNull();
+      lastIdDeactivationValidation = account.account().id().get();
+      if (failDeactivationValidations) {
+        throw new ValidationException("testing validation failure");
+      }
+    }
+
+    public void failActivationValidations() {
+      failActivationValidations = true;
+    }
+
+    public void failDeactivationValidations() {
+      failDeactivationValidations = true;
+    }
+
+    private void assertNoMoreEvents() {
+      assertThat(lastIdActivationValidation).isNull();
+      assertThat(lastIdDeactivationValidation).isNull();
+    }
+
+    private void assertActivationValidation(int id) {
+      assertThat(lastIdActivationValidation).isEqualTo(id);
+      lastIdActivationValidation = null;
+    }
+
+    private void assertDeactivationValidation(int id) {
+      assertThat(lastIdDeactivationValidation).isEqualTo(id);
+      lastIdDeactivationValidation = null;
+    }
+  }
+
+  @Singleton
+  public static class Listener implements AccountActivationListener {
+    private Integer lastIdActivated;
+    private Integer lastIdDeactivated;
+
+    @Override
+    public void onAccountActivated(int id) {
+      assertThat(lastIdActivated).isNull();
+      lastIdActivated = id;
+    }
+
+    @Override
+    public void onAccountDeactivated(int id) {
+      assertThat(lastIdDeactivated).isNull();
+      lastIdDeactivated = id;
+    }
+
+    private void assertNoMoreEvents() {
+      assertThat(lastIdActivated).isNull();
+      assertThat(lastIdDeactivated).isNull();
+    }
+
+    private void assertDeactivated(int id) {
+      assertThat(lastIdDeactivated).isEqualTo(id);
+      lastIdDeactivated = null;
+    }
+
+    private void assertActivated(int id) {
+      assertThat(lastIdActivated).isEqualTo(id);
+      lastIdActivated = null;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index a09284e..b41a2f3 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -33,12 +35,16 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.gerrit.server.account.SetInactiveFlag;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.inject.Inject;
+import com.google.inject.util.Providers;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.Repository;
@@ -51,6 +57,12 @@
   @Inject @ServerInitiated private AccountsUpdate accountsUpdate;
   @Inject private ExternalIdNotes.Factory extIdNotesFactory;
 
+  @Inject private Sequences sequences;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private SshKeyCache sshKeyCache;
+  @Inject private GroupsUpdate.Factory groupsUpdateFactory;
+  @Inject private SetInactiveFlag setInactiveFlag;
+
   @Test
   public void authenticateNewAccountWithEmail() throws Exception {
     String email = "foo@example.com";
@@ -200,6 +212,31 @@
 
   @Test
   public void authenticateWithUsernameAndUpdateDisplayName() throws Exception {
+    authenticateWithUsernameAndUpdateDisplayName(accountManager);
+  }
+
+  @Test
+  public void readOnlyFullNameField_authenticateWithUsernameAndUpdateDisplayName()
+      throws Exception {
+    TestRealm realm = server.getTestInjector().getInstance(TestRealm.class);
+    realm.denyEdit(AccountFieldName.FULL_NAME);
+    authenticateWithUsernameAndUpdateDisplayName(
+        new AccountManager(
+            sequences,
+            cfg,
+            accounts,
+            Providers.of(accountsUpdate),
+            accountCache,
+            realm,
+            userFactory,
+            sshKeyCache,
+            projectCache,
+            externalIds,
+            groupsUpdateFactory,
+            setInactiveFlag));
+  }
+
+  private void authenticateWithUsernameAndUpdateDisplayName(AccountManager am) throws Exception {
     String username = "foo";
     String email = "foo@example.com";
     Account.Id accountId = Account.id(seq.nextAccountId());
@@ -215,7 +252,7 @@
     AuthRequest who = AuthRequest.forUser(username);
     String newName = "Updated Name";
     who.setDisplayName(newName);
-    AuthResult authResult = accountManager.authenticate(who);
+    AuthResult authResult = am.authenticate(who);
     assertAuthResultForExistingAccount(authResult, accountId, gerritExtIdKey);
 
     Optional<AccountState> accountState = accounts.get(accountId);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 7d97934..11ca391 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.ContributorAgreement;
@@ -64,7 +63,6 @@
   private ContributorAgreement caNoAutoVerify;
   @Inject private GroupOperations groupOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private ProjectOperations projectOperations;
 
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index 2167d27..673379d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -1,3 +1,4 @@
+load("@rules_java//java:defs.bzl", "java_library")
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
 acceptance_tests(
@@ -9,8 +10,18 @@
         "no_windows",
     ],
     deps = [
+        ":util",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/server/util/time",
     ],
 )
+
+java_library(
+    name = "util",
+    testonly = True,
+    srcs = glob(["TestRealm.java"]),
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
new file mode 100644
index 0000000..a8fd834
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.mail.Address;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Instant;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class MessageIdGeneratorIT extends AbstractDaemonTest {
+  @Inject private MessageIdGenerator messageIdGenerator;
+
+  @Test
+  public void fromAccountUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
+      String sha1 =
+          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromChangeUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      PushOneCommit.Result result = createChange();
+      PatchSet.Id patchsetId = result.getChange().currentPatchSet().id();
+      String messageId = messageIdGenerator.fromChangeUpdate(project, patchsetId).id();
+      String sha1 =
+          repo.getRefDatabase()
+              .findRef(String.format("%smeta", patchsetId.changeId().toRefPrefix()))
+              .getObjectId()
+              .getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromMailMessage() throws Exception {
+    String id = "unique-id";
+    MailMessage mailMessage =
+        MailMessage.builder()
+            .id(id)
+            .from(Address.create("email@email.com"))
+            .dateReceived(Instant.EPOCH)
+            .subject("subject")
+            .build();
+    assertThat(messageIdGenerator.fromMailMessage(mailMessage).id()).isEqualTo(id + "-REJECTION");
+  }
+
+  @Test
+  public void fromReasonAccountIdAndTimestamp() throws Exception {
+    String reason = "reason";
+    Instant timestamp = TimeUtil.now();
+    assertThat(
+            messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
+        .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/TestRealm.java b/javatests/com/google/gerrit/acceptance/api/accounts/TestRealm.java
new file mode 100644
index 0000000..94b6cd3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/TestRealm.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.server.account.DefaultRealm;
+import com.google.gerrit.server.account.EmailExpander;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.HashSet;
+import java.util.Set;
+
+@Singleton
+public class TestRealm extends DefaultRealm {
+
+  private final Set<AccountFieldName> readOnlyFields = new HashSet<>();
+
+  @Inject
+  public TestRealm(EmailExpander emailExpander, Provider<Emails> emails, AuthConfig authConfig) {
+    super(emailExpander, emails, authConfig);
+  }
+
+  public void denyEdit(AccountFieldName field) {
+    readOnlyFields.add(field);
+  }
+
+  @Override
+  public boolean allowsEdit(AccountFieldName field) {
+    return !readOnlyFields.contains(field);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 82a19d4..744035e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -3065,7 +3065,8 @@
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.updated, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.updated, serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -3074,7 +3075,8 @@
       RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index be0cc04..7d73374 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -31,13 +31,16 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -54,6 +57,7 @@
 import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -64,6 +68,7 @@
 
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -382,6 +387,93 @@
         .contains("Exceeding maximum cumulative size of comments");
   }
 
+  @Test
+  public void ccToReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+
+    // CC -> Reviewer
+    ReviewInput input2 = new ReviewInput().reviewer(user.id().toString());
+    gApi.changes().id(r.getChangeId()).current().review(input2);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers2 =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers2).hasSize(1);
+    AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.REVIEWER));
+    assertThat(reviewer2._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void reviewerToCc() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().reviewer(user.id().toString());
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+
+    // Reviewer -> CC
+    ReviewInput input2 = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false);
+    gApi.changes().id(r.getChangeId()).current().review(input2);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers2 =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers2).hasSize(1);
+    AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.CC));
+    assertThat(reviewer2._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void votingMakesCallerReviewer() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().label("Code-Review", 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void commentingMakesUserCC() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().message("Foo bar!");
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+  }
+
   private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
     return gApi.changes().id(changeId).robotComments().values().stream()
         .flatMap(Collection::stream)
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 1083377..14288b5 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -1071,7 +1071,7 @@
 
       PersonIdent committer = serverIdent.get();
       PersonIdent author =
-          noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+          noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
       tr.branch(RefNames.changeMetaRef(cd3.getId()))
           .commit()
           .author(author)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index 38b7e0e..cf349ab 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
 import org.eclipse.jgit.lib.Config;
-import org.junit.Before;
 
 public class ElasticReindexIT extends AbstractReindexTests {
 
@@ -39,10 +38,4 @@
   public void configureIndex(Injector injector) {
     createAllIndexes(injector);
   }
-
-  @Before
-  public void reindexFirstSinceElastic() throws Exception {
-    assertServerStartupFails();
-    runGerrit("reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 2779284..2901361 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -1348,7 +1348,7 @@
     } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertThat(last).startsWith("Change has been successfully rebased and submitted as");
     } else {
-      assertThat(last).isEqualTo("Change has been successfully merged by Administrator");
+      assertThat(last).isEqualTo("Change has been successfully merged by Gerrit User 1000000");
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index a0ebf02..68e9b14 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -429,7 +429,8 @@
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index fcf0e6d..0514e03 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -17,7 +17,9 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
@@ -31,6 +33,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -92,7 +95,7 @@
 
   @Test
   public void grantRevertPermission() throws Exception {
-    String ref = "refs/heads/*";
+    String ref = "refs/*";
     String groupId = "global:Registered-Users";
 
     grantRevertPermission.execute(newProjectName);
@@ -108,6 +111,69 @@
   }
 
   @Test
+  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
+    String refsHeads = "refs/heads/*";
+    String refsStar = "refs/*";
+    String groupId = "global:Registered-Users";
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
+      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+
+    // Revert permission is removed on refs/heads/*.
+    assertThat(info.local.containsKey(refsHeads)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
+
+    // new permission is added on refs/* with Registered-Users.
+    assertThat(info.local.containsKey(refsStar)).isTrue();
+    accessSectionInfo = info.local.get(refsStar);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
+      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+      grant(projectConfig, heads, Permission.REVERT, otherGroup);
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    ProjectAccessInfo expected = pApi().access();
+
+    grantRevertPermission.execute(newProjectName);
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo actual = pApi().access();
+    // Permissions don't change
+    assertThat(expected.local).isEqualTo(actual.local);
+  }
+
+  @Test
   public void grantRevertPermissionOnlyWorksOnce() throws Exception {
     grantRevertPermission.execute(newProjectName);
     grantRevertPermission.execute(newProjectName);
@@ -115,9 +181,9 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
 
-      Permission permission = heads.getPermission(Permission.REVERT);
+      Permission permission = all.getPermission(Permission.REVERT);
       assertThat(permission.getRules()).hasSize(1);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 069387c..e39f967 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -831,7 +831,7 @@
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+        noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index fc44822..d7d67b8 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.extensions.validators.CommentForValidation;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
@@ -47,6 +48,7 @@
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.net.URL;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
@@ -296,6 +298,10 @@
     assertNotifyTo(user);
     Message message = sender.nextMessage();
     assertThat(message.body()).contains("rejected one or more comments");
+
+    // ensure the message header contains a valid message id.
+    assertThat(((EmailHeader.String) (message.headers().get("Message-ID"))).getString())
+        .containsMatch("<someid-REJECTION-HTML@" + new URL(canonicalWebUrl.get()).getHost() + ">");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 5421d8c..bbe7b81 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Streams;
@@ -29,6 +30,9 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Spliterator;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
 import org.junit.Test;
 
 @NoHttpd
@@ -165,6 +169,38 @@
     assertThat(commands).containsExactlyElementsIn(SLAVE_COMMANDS.get("gerrit")).inOrder();
   }
 
+  @Test
+  @Sandboxed
+  public void showConnections() throws Exception {
+    Spliterator<String> connectionsOutput =
+        getOutputLines(adminSshSession.exec("gerrit show-connections"));
+
+    assertThat(findConnectionsInOutput(connectionsOutput, "user")).hasSize(1);
+  }
+
+  @Test
+  @Sandboxed
+  public void cloeConnections() throws Exception {
+    List<String> connectionsOutput =
+        findConnectionsInOutput(
+            getOutputLines(adminSshSession.exec("gerrit show-connections")), "user");
+    String connectionId =
+        Splitter.on(" ")
+            .trimResults()
+            .omitEmptyStrings()
+            .split(connectionsOutput.get(0))
+            .iterator()
+            .next();
+
+    String closeConnectionOutput = adminSshSession.exec("gerrit close-connection " + connectionId);
+    assertThat(closeConnectionOutput).contains(connectionId);
+
+    assertThat(
+            findConnectionsInOutput(
+                getOutputLines(adminSshSession.exec("gerrit show-connections")), "user"))
+        .isEmpty();
+  }
+
   private List<String> parseCommandsFromGerritHelpText(String helpText) {
     List<String> commands = new ArrayList<>();
 
@@ -197,4 +233,16 @@
 
     return commands;
   }
+
+  private List<String> findConnectionsInOutput(Spliterator<String> connectionsOutput, String user) {
+    List<String> connections =
+        StreamSupport.stream(connectionsOutput, false)
+            .filter(s -> s.contains("localhost") && s.contains(user))
+            .collect(Collectors.toList());
+    return connections;
+  }
+
+  private Spliterator<String> getOutputLines(String output) throws Exception {
+    return Splitter.on("\n").trimResults().omitEmptyStrings().split(output).spliterator();
+  }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 15094fd..c20650f 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -43,7 +43,7 @@
       case V6_7:
         return "blacktop/elasticsearch:6.7.2";
       case V6_8:
-        return "blacktop/elasticsearch:6.8.9";
+        return "blacktop/elasticsearch:6.8.10";
       case V7_0:
         return "blacktop/elasticsearch:7.0.1";
       case V7_1:
@@ -59,7 +59,7 @@
       case V7_6:
         return "blacktop/elasticsearch:7.6.2";
       case V7_7:
-        return "blacktop/elasticsearch:7.7.0";
+        return "blacktop/elasticsearch:7.7.1";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
diff --git a/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java b/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java
new file mode 100644
index 0000000..2679829
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/AllowRenderInFrameFilterTest.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.AllowRenderInFrameFilter.X_FRAME_OPTIONS_HEADER_NAME;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.google.gerrit.httpd.AllowRenderInFrameFilter.XFrameOption;
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AllowRenderInFrameFilterTest {
+
+  private Config cfg = new Config();
+  @Mock ServletRequest request;
+  @Mock HttpServletResponse response;
+  @Mock FilterChain filterChain;
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalse()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalseAndXFormOptionIsSAMEORIGIN()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldDenyInFrameRenderingWhenCanRenderInFrameIsFalseAndXFormOptionIsALLOW()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", false);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.ALLOW);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "DENY");
+  }
+
+  @Test
+  public void shouldRestrictAccessToSAMEORIGINWhenCanRenderInFrameIsTrue()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldSkipHeaderWhenCanRenderInFrameIsTrueAndXFormOptionIsALLOW()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.ALLOW);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, never()).addHeader(eq(X_FRAME_OPTIONS_HEADER_NAME), anyString());
+  }
+
+  @Test
+  public void shouldRestrictAccessToSAMEORIGINWhenCanRenderInFrameIsTrueAndXFormOptionIsSAMEORIGIN()
+      throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setEnum("gerrit", null, "xframeOption", XFrameOption.SAMEORIGIN);
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldIgnoreXFrameOriginCaseSensitivity() throws IOException, ServletException {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setString("gerrit", null, "xframeOption", "sameOrigin");
+
+    AllowRenderInFrameFilter objectUnderTest = new AllowRenderInFrameFilter(cfg);
+    objectUnderTest.doFilter(request, response, filterChain);
+
+    verify(response, times(1)).addHeader(X_FRAME_OPTIONS_HEADER_NAME, "SAMEORIGIN");
+  }
+
+  @Test
+  public void shouldThrowExceptionWhenUnknownXFormOptionValue() {
+    cfg.setBoolean("gerrit", null, "canLoadInIFrame", true);
+    cfg.setString("gerrit", null, "xframeOption", "unsupported value");
+
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> new AllowRenderInFrameFilter(cfg));
+    assertThat(e).hasMessageThat().contains("gerrit.xframeOption=unsupported value");
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/ssh/BUILD b/javatests/com/google/gerrit/integration/ssh/BUILD
new file mode 100644
index 0000000..dc8e68c
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/ssh/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = ["PeerKeysAuthIT.java"],
+    group = "peer-keys-auth",
+    labels = ["ssh"],
+)
diff --git a/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
new file mode 100644
index 0000000..a219cc2
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.integration.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.ISO_8859_1;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.server.PeerDaemonUser;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class PeerKeysAuthIT extends StandaloneSiteTest {
+  private static final String[] SSH_KEYGEN_CMD =
+      new String[] {"ssh-keygen", "-t", "rsa", "-q", "-P", "", "-f", "id_rsa"};
+  private static final String[] SSH_COMMAND =
+      new String[] {
+        "ssh",
+        "-o",
+        "UserKnownHostsFile=/dev/null",
+        "-o",
+        "StrictHostKeyChecking=no",
+        "-o",
+        "IdentitiesOnly=yes",
+        "-i"
+      };
+
+  @Inject private @TestSshServerAddress InetSocketAddress sshAddress;
+
+  @Test
+  public void test() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+      // Generate private/public key for user
+      execute(ImmutableList.<String>builder().add(SSH_KEYGEN_CMD).build());
+
+      String[] parts =
+          new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8)
+              .split(" ");
+
+      // Loose algorithm at index 0, verify the format: "key comment"
+      Files.write(
+          sitePaths.peer_keys, String.format("%s %s", parts[1], parts[2]).getBytes(ISO_8859_1));
+      assertContent(execGerritVersionCommand());
+
+      // Only preserve the key material: no algorithm and no comment
+      Files.write(sitePaths.peer_keys, parts[1].getBytes(ISO_8859_1));
+      assertContent(execGerritVersionCommand());
+
+      // Wipe out the content of the peer keys file
+      Files.delete(sitePaths.peer_keys);
+      assertThrows(IOException.class, () -> execGerritVersionCommand());
+    }
+  }
+
+  private String execGerritVersionCommand() throws Exception {
+    return execute(
+        ImmutableList.<String>builder()
+            .add(SSH_COMMAND)
+            .add(sitePaths.data_dir.resolve("id_rsa").toString())
+            .add("-p " + sshAddress.getPort())
+            .add(PeerDaemonUser.USER_NAME + "@" + sshAddress.getHostName())
+            .add("suexec")
+            .add("--as")
+            .add("admin")
+            .add("--")
+            .add("gerrit")
+            .add("version")
+            .build());
+  }
+
+  private String execute(ImmutableList<String> cmd) throws Exception {
+    return execute(cmd, sitePaths.data_dir.toFile(), ImmutableMap.of());
+  }
+
+  private static void assertContent(String result) {
+    assertThat(result).contains("gerrit version " + Version.getVersion());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 98f1b0e..438990c 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,10 +6,12 @@
     deps = [
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
         "//lib/guice",
+        "//lib/mockito",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 69c2799..ddcfe0c 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -16,16 +16,27 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.TypeLiteral;
+import java.time.Duration;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nullable;
 import org.junit.Test;
 
 public class H2CacheTest {
@@ -38,23 +49,31 @@
   }
 
   private static H2CacheImpl<String, String> newH2CacheImpl(
-      int id, Cache<String, ValueHolder<String>> mem, int version) {
-    SqlStore<String, String> store =
-        new SqlStore<>(
-            "jdbc:h2:mem:Test_" + id,
-            KEY_TYPE,
-            StringCacheSerializer.INSTANCE,
-            StringCacheSerializer.INSTANCE,
-            version,
-            1 << 20,
-            null);
+      SqlStore<String, String> store, Cache<String, ValueHolder<String>> mem) {
     return new H2CacheImpl<>(MoreExecutors.directExecutor(), store, KEY_TYPE, mem);
   }
 
+  private static SqlStore<String, String> newStore(
+      int id,
+      int version,
+      @Nullable Duration expireAfterWrite,
+      @Nullable Duration refreshAfterWrite) {
+    return new SqlStore<>(
+        "jdbc:h2:mem:Test_" + id,
+        KEY_TYPE,
+        StringCacheSerializer.INSTANCE,
+        StringCacheSerializer.INSTANCE,
+        version,
+        1 << 20,
+        expireAfterWrite,
+        refreshAfterWrite);
+  }
+
   @Test
   public void get() throws ExecutionException {
     Cache<String, ValueHolder<String>> mem = CacheBuilder.newBuilder().build();
-    H2CacheImpl<String, String> impl = newH2CacheImpl(nextDbId(), mem, DEFAULT_VERSION);
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
 
     assertThat(impl.getIfPresent("foo")).isNull();
 
@@ -94,11 +113,12 @@
   }
 
   @Test
-  public void version() throws Exception {
+  public void version() {
     int id = nextDbId();
-    H2CacheImpl<String, String> oldImpl = newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION);
+    H2CacheImpl<String, String> oldImpl =
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION, null, null), disableMemCache());
     H2CacheImpl<String, String> newImpl =
-        newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION + 1);
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION + 1, null, null), disableMemCache());
 
     assertThat(oldImpl.diskStats().space()).isEqualTo(0);
     assertThat(newImpl.diskStats().space()).isEqualTo(0);
@@ -124,6 +144,57 @@
     assertThat(oldImpl.getIfPresent("key")).isNull();
   }
 
+  @Test
+  public void refreshAfterWrite_triggeredWhenConfigured() throws Exception {
+    SqlStore<String, String> store =
+        newStore(nextDbId(), DEFAULT_VERSION, null, Duration.ofMillis(10));
+
+    // This is the loader that we configure for the cache when calling .loader(...)
+    @SuppressWarnings("unchecked")
+    CacheLoader<String, String> baseLoader = mock(CacheLoader.class);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // We wrap baseLoader just like H2CacheFactory is wrapping it. The wrapped version will call out
+    // to the store for refreshing values.
+    H2CacheImpl.Loader<String, String> wrappedLoader =
+        new H2CacheImpl.Loader<>(MoreExecutors.directExecutor(), store, baseLoader);
+    // memCache is the in-memory variant of the cache. Its loader is wrappedLoader which will call
+    // out to the store to save or delete cached values.
+    LoadingCache<String, ValueHolder<String>> memCache =
+        CacheBuilder.newBuilder().maximumSize(10).build(wrappedLoader);
+
+    // h2Cache puts it all together
+    H2CacheImpl<String, String> h2Cache = newH2CacheImpl(store, memCache);
+
+    // Initial load and cache retrieval do not trigger refresh
+    // This works because we use a directExecutor() for refreshes
+    TimeUtil.setCurrentMillisSupplier(() -> 0);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verify(baseLoader).load("foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Load after refresh duration returns old value, triggers refresh and returns new value
+    TimeUtil.setCurrentMillisSupplier(() -> 11);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("reload:foo");
+    verify(baseLoader).reload("foo", "load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Refreshed value was persisted
+    memCache.invalidateAll(); // Invalidates only the memcache, not the store.
+    assertThat(h2Cache.getIfPresent("foo")).isEqualTo("reload:foo");
+  }
+
+  private static void resetLoaderAndAnswerLoadAndRefreshCalls(CacheLoader<String, String> loader)
+      throws Exception {
+    reset(loader);
+    when(loader.load("foo")).thenReturn("load:foo");
+    when(loader.reload("foo", "load:foo")).thenReturn(Futures.immediateFuture("reload:foo"));
+  }
+
   private static <K, V> Cache<K, ValueHolder<V>> disableMemCache() {
     return CacheBuilder.newBuilder().maximumSize(0).build();
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 8ffcc8b..5bfe97c 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -519,7 +519,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
         false);
   }
 
@@ -531,7 +531,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
         initWorkInProgress);
   }
 
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 5a28404..daefd7c 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -14,77 +14,126 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
-
   @Test
-  public void commentsLinkedToChangeMessages() {
-    /**
-     * Human comments should not be linked to auto-generated messages. A comment is linked to the
-     * nearest next change message in timestamp
-     */
-    CommentInfo c1 = getNewCommentInfo("c1", Timestamp.valueOf("2018-01-01 09:01:00"));
-    CommentInfo c2 = getNewCommentInfo("c2", Timestamp.valueOf("2018-01-01 09:01:15"));
-    CommentInfo c3 = getNewCommentInfo("c3", Timestamp.valueOf("2018-01-01 09:01:25"));
+  public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
+    /* Comments should not be linked to Gerrit's autogenerated messages */
+    List<CommentInfo> comments = createComments("c1", "00", "c2", "10", "c3", "25");
+    List<ChangeMessage> changeMessages =
+        createChangeMessages("cm1", "00", "cm2", "16", "cm3", "30");
 
-    ChangeMessage cm1 =
-        getNewChangeMessage("cm1key", "cm1", Timestamp.valueOf("2018-01-01 00:00:00"), null);
-    ChangeMessage cmIgnore =
-        getNewChangeMessage(
-            "cm2key",
-            "cm2",
-            Timestamp.valueOf("2018-01-01 09:01:15"),
-            ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX);
-    ChangeMessage cm2 =
-        getNewChangeMessage("cm2key", "cm2", Timestamp.valueOf("2018-01-01 09:01:16"), null);
-    ChangeMessage cm3 =
-        getNewChangeMessage("cm3key", "cm3", Timestamp.valueOf("2018-01-01 09:01:27"), null);
-
-    assertThat(c1.changeMessageId).isNull();
-    assertThat(c2.changeMessageId).isNull();
-    assertThat(c3.changeMessageId).isNull();
-
-    ImmutableList<CommentInfo> comments = ImmutableList.of(c1, c2, c3);
-    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(cm1, cmIgnore, cm2, cm3);
+    changeMessages.add(
+        newChangeMessage("ignore", "cmAutoGenByGerrit", "15", ChangeMessagesUtil.TAG_MERGED));
 
     CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, true);
 
-    assertThat(c1.changeMessageId).isEqualTo(changeMessageKey(cm2));
-    /** comment 2 ignored the auto-generated change message */
-    assertThat(c2.changeMessageId).isEqualTo(changeMessageKey(cm2));
-    assertThat(c3.changeMessageId).isEqualTo(changeMessageKey(cm3));
+    assertThat(getComment(comments, "c1").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+    /* comment 2 ignored the auto-generated message because it has a Gerrit tag */
+    assertThat(getComment(comments, "c2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm2").getKey().uuid());
+    assertThat(getComment(comments, "c3").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
+
+    // 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"));
   }
 
-  private static CommentInfo getNewCommentInfo(String message, Timestamp ts) {
+  @Test
+  public void commentsLinkedToChangeMessagesAllowLinkingToAutoGenTaggedMessages() {
+    /* Human comments are allowed to be linked to autogenerated messages */
+    List<CommentInfo> comments = createComments("c1", "00", "c2", "10", "c3", "25");
+    List<ChangeMessage> changeMessages =
+        createChangeMessages("cm1", "00", "cm2", "16", "cm3", "30");
+
+    changeMessages.add(
+        newChangeMessage(
+            "cmAutoGen", "cmAutoGen", "15", ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX));
+
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, true);
+
+    assertThat(getComment(comments, "c1").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+    /* comment 2 did not ignore the auto-generated change message */
+    assertThat(getComment(comments, "c2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cmAutoGen").getKey().uuid());
+    assertThat(getComment(comments, "c3").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
+  }
+
+  /**
+   * Create a list of comments from the specified args args should be passed as consecutive pairs of
+   * messages and timestamps example: (m1, t1, m2, t2, ...)
+   */
+  private static List<CommentInfo> createComments(String... args) {
+    List<CommentInfo> comments = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      String message = args[i];
+      String ts = args[i + 1];
+      comments.add(newCommentInfo(message, ts));
+    }
+    return comments;
+  }
+
+  /**
+   * Create a list of change messages from the specified args args should be passed as consecutive
+   * pairs of messages and timestamps example: (m1, t1, m2, t2, ...). the tag parameter for the
+   * created change messages will be null.
+   */
+  private static List<ChangeMessage> createChangeMessages(String... args) {
+    List<ChangeMessage> changeMessages = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      String key = args[i] + "Key";
+      String message = args[i];
+      String ts = args[i + 1];
+      changeMessages.add(newChangeMessage(key, message, ts, null));
+    }
+    return changeMessages;
+  }
+
+  /** Create a new CommentInfo with a given message and timestamp */
+  private static CommentInfo newCommentInfo(String message, String ts) {
     CommentInfo c = new CommentInfo();
     c.message = message;
-    c.updated = ts;
+    c.updated = Timestamp.valueOf("2000-01-01 00:00:" + ts);
     return c;
   }
 
-  private static ChangeMessage getNewChangeMessage(
-      String id, String message, Timestamp ts, String tag) {
+  /** Create a new change message with an id, message, timestamp and tag */
+  private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
-    ChangeMessage cm = new ChangeMessage(key, null, ts, null);
+    ChangeMessage cm =
+        new ChangeMessage(key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null);
     cm.setMessage(message);
     cm.setTag(tag);
     return cm;
   }
 
-  private static String changeMessageKey(ChangeMessage changeMessage) {
-    return changeMessage.getKey().uuid();
+  /** Return the change message from the list of messages that has specific message text */
+  private static ChangeMessage getChangeMessage(List<ChangeMessage> messages, String messageText) {
+    return messages.stream().filter(m -> m.getMessage().equals(messageText)).collect(onlyElement());
+  }
+
+  /** Return the comment from the list of comments that has specific message text */
+  private CommentInfo getComment(List<CommentInfo> comments, String messageText) {
+    return comments.stream().filter(c -> c.message.equals(messageText)).collect(onlyElement());
   }
 }
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
new file mode 100644
index 0000000..a5fd4a2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.inject.ProvisionException;
+import java.io.IOException;
+import java.nio.file.Paths;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbSchemaVersionCheckTest {
+  private NoteDbSchemaVersionManager versionManager;
+  private SitePaths sitePaths;
+
+  @Before
+  public void setup() throws Exception {
+    AllProjectsName allProjectsName = new AllProjectsName("All-Projects");
+    GitRepositoryManager repoManager = new InMemoryRepositoryManager();
+    repoManager.createRepository(allProjectsName);
+    versionManager = new NoteDbSchemaVersionManager(allProjectsName, repoManager);
+    versionManager.init();
+
+    sitePaths = new SitePaths(Paths.get("/tmp/foo"));
+  }
+
+  @Test
+  public void shouldNotFailIfCurrentVersionIsExpected() {
+    new NoteDbSchemaVersionCheck(versionManager, sitePaths, new Config()).start();
+    // No exceptions should be thrown
+  }
+
+  @Test
+  public void shouldFailIfCurrentVersionIsOneMoreThanExpected() throws IOException {
+    versionManager.increment(NoteDbSchemaVersions.LATEST);
+
+    ProvisionException e =
+        assertThrows(
+            ProvisionException.class,
+            () -> new NoteDbSchemaVersionCheck(versionManager, sitePaths, new Config()).start());
+
+    assertThat(e)
+        .hasMessageThat()
+        .contains("Unsupported schema version " + (NoteDbSchemaVersions.LATEST + 1));
+  }
+
+  @Test
+  public void
+      shouldNotFailWithExperimentalRollingUpgradeEnabledAndCurrentVersionIsOneMoreThanExpected()
+          throws IOException {
+    Config gerritConfig = new Config();
+    gerritConfig.setBoolean("gerrit", null, "experimentalRollingUpgrade", true);
+    versionManager.increment(NoteDbSchemaVersions.LATEST);
+
+    NoteDbSchemaVersionCheck versionCheck =
+        new NoteDbSchemaVersionCheck(versionManager, sitePaths, gerritConfig);
+    versionCheck.start();
+    // No exceptions should be thrown
+  }
+}
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
new file mode 100644
index 0000000..dbcc209
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.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.server.submit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.submit.SubscriptionGraph.DefaultFactory;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+public class SubscriptionGraphTest {
+  private static final String TEST_PATH = "test/path";
+  private static final Project.NameKey SUPER_PROJECT = Project.nameKey("Superproject");
+  private static final Project.NameKey SUB_PROJECT = Project.nameKey("Subproject");
+  private static final BranchNameKey SUPER_BRANCH =
+      BranchNameKey.create(SUPER_PROJECT, "refs/heads/one");
+  private static final BranchNameKey SUB_BRANCH =
+      BranchNameKey.create(SUB_PROJECT, "refs/heads/one");
+  private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private MergeOpRepoManager mergeOpRepoManager;
+
+  @Mock GitModules.Factory mockGitModulesFactory = mock(GitModules.Factory.class);
+  @Mock ProjectCache mockProjectCache = mock(ProjectCache.class);
+  @Mock ProjectState mockProjectState = mock(ProjectState.class);
+
+  @Before
+  public void setUp() throws Exception {
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    GitModules emptyMockGitModules = mock(GitModules.class);
+    when(emptyMockGitModules.subscribedTo(any())).thenReturn(ImmutableSet.of());
+    when(mockGitModulesFactory.create(any(), any())).thenReturn(emptyMockGitModules);
+
+    TestRepository<Repository> superProject = createRepo(SUPER_PROJECT);
+    TestRepository<Repository> submoduleProject = createRepo(SUB_PROJECT);
+
+    // Make sure that SUPER_BRANCH and SUB_BRANCH can be subscribed.
+    allowSubscription(SUPER_BRANCH);
+    allowSubscription(SUB_BRANCH);
+
+    setSubscription(SUB_BRANCH, ImmutableList.of(SUPER_BRANCH));
+    setSubscription(SUPER_BRANCH, ImmutableList.of());
+    createBranch(
+        superProject, SUPER_BRANCH, superProject.commit().message("Initial commit").create());
+    createBranch(
+        submoduleProject, SUB_BRANCH, submoduleProject.commit().message("Initial commit").create());
+  }
+
+  @Test
+  public void oneSuperprojectOneSubmodule() throws Exception {
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    SubscriptionGraph subscriptionGraph = factory.compute(ImmutableSet.of(SUB_BRANCH));
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects()).containsExactly(SUPER_PROJECT);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(SUPER_PROJECT))
+        .containsExactly(SUPER_BRANCH);
+    assertThat(subscriptionGraph.getSubscriptions(SUPER_BRANCH))
+        .containsExactly(new SubmoduleSubscription(SUPER_BRANCH, SUB_BRANCH, TEST_PATH));
+    assertThat(subscriptionGraph.hasSuperproject(SUB_BRANCH)).isTrue();
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(SUB_BRANCH, SUPER_BRANCH)
+        .inOrder();
+  }
+
+  @Test
+  public void circularSubscription() throws Exception {
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    setSubscription(SUPER_BRANCH, ImmutableList.of(SUB_BRANCH));
+    SubmoduleConflictException e =
+        assertThrows(
+            SubmoduleConflictException.class, () -> factory.compute(ImmutableSet.of(SUB_BRANCH)));
+
+    String expectedErrorMessage =
+        "Subproject,refs/heads/one->Superproject,refs/heads/one->Subproject,refs/heads/one";
+    assertThat(e).hasMessageThat().contains(expectedErrorMessage);
+  }
+
+  @Test
+  public void multipleSuperprojectsToMultipleSubmodules() throws Exception {
+    // Create superprojects and subprojects.
+    Project.NameKey superProject1 = Project.nameKey("superproject1");
+    Project.NameKey superProject2 = Project.nameKey("superproject2");
+    Project.NameKey subProject1 = Project.nameKey("subproject1");
+    Project.NameKey subProject2 = Project.nameKey("subproject2");
+    TestRepository<Repository> superProjectRepo1 = createRepo(superProject1);
+    TestRepository<Repository> superProjectRepo2 = createRepo(superProject2);
+    TestRepository<Repository> submoduleRepo1 = createRepo(subProject1);
+    TestRepository<Repository> submoduleRepo2 = createRepo(subProject2);
+
+    // Initialize super branches.
+    BranchNameKey superBranch1 = BranchNameKey.create(superProject1, "refs/heads/one");
+    BranchNameKey superBranch2 = BranchNameKey.create(superProject2, "refs/heads/one");
+    createBranch(
+        superProjectRepo1,
+        superBranch1,
+        superProjectRepo1.commit().message("Initial commit").create());
+    createBranch(
+        superProjectRepo2,
+        superBranch2,
+        superProjectRepo2.commit().message("Initial commit").create());
+
+    // Initialize sub branches.
+    BranchNameKey submoduleBranch1 = BranchNameKey.create(subProject1, "refs/heads/one");
+    BranchNameKey submoduleBranch2 = BranchNameKey.create(subProject1, "refs/heads/two");
+    BranchNameKey submoduleBranch3 = BranchNameKey.create(subProject2, "refs/heads/one");
+    createBranch(
+        submoduleRepo1, submoduleBranch1, submoduleRepo1.commit().message("Commit1").create());
+    createBranch(
+        submoduleRepo1, submoduleBranch2, submoduleRepo1.commit().message("Commit2").create());
+    createBranch(
+        submoduleRepo2, submoduleBranch3, submoduleRepo2.commit().message("Commit1").create());
+
+    allowSubscription(submoduleBranch1);
+    allowSubscription(submoduleBranch2);
+    allowSubscription(submoduleBranch3);
+
+    // Initialize subscriptions.
+    setSubscription(submoduleBranch1, ImmutableList.of(superBranch1, superBranch2));
+    setSubscription(submoduleBranch2, ImmutableList.of(superBranch1));
+    setSubscription(submoduleBranch3, ImmutableList.of(superBranch1, superBranch2));
+
+    SubscriptionGraph.Factory factory =
+        new DefaultFactory(mockGitModulesFactory, mockProjectCache, mergeOpRepoManager);
+    SubscriptionGraph subscriptionGraph =
+        factory.compute(ImmutableSet.of(submoduleBranch1, submoduleBranch2));
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects())
+        .containsExactly(superProject1, superProject2);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject1))
+        .containsExactly(superBranch1);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject2))
+        .containsExactly(superBranch2);
+
+    assertThat(subscriptionGraph.getSubscriptions(superBranch1))
+        .containsExactly(
+            new SubmoduleSubscription(superBranch1, submoduleBranch1, TEST_PATH),
+            new SubmoduleSubscription(superBranch1, submoduleBranch2, TEST_PATH));
+    assertThat(subscriptionGraph.getSubscriptions(superBranch2))
+        .containsExactly(new SubmoduleSubscription(superBranch2, submoduleBranch1, TEST_PATH));
+
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch1)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch2)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch3)).isFalse();
+
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(submoduleBranch2, submoduleBranch1, superBranch2, superBranch1)
+        .inOrder();
+  }
+
+  private TestRepository<Repository> createRepo(Project.NameKey project) throws Exception {
+    Repository repo = repoManager.createRepository(project);
+    return new TestRepository<>(repo);
+  }
+
+  private void createBranch(TestRepository<Repository> repo, BranchNameKey branch, RevCommit commit)
+      throws Exception {
+    repo.update(branch.branch(), commit);
+  }
+
+  private void allowSubscription(BranchNameKey branch) {
+    SubscribeSection s = new SubscribeSection(branch.project());
+    s.addMultiMatchRefSpec("refs/heads/*:refs/heads/*");
+    when(mockProjectState.getSubscribeSections(branch)).thenReturn(ImmutableSet.of(s));
+  }
+
+  private void setSubscription(
+      BranchNameKey submoduleBranch, List<BranchNameKey> superprojectBranches) {
+    List<SubmoduleSubscription> subscriptions =
+        superprojectBranches.stream()
+            .map(
+                (targetBranch) ->
+                    new SubmoduleSubscription(targetBranch, submoduleBranch, TEST_PATH))
+            .collect(Collectors.toList());
+    GitModules mockGitModules = mock(GitModules.class);
+    when(mockGitModules.subscribedTo(submoduleBranch)).thenReturn(subscriptions);
+    when(mockGitModulesFactory.create(submoduleBranch, mergeOpRepoManager))
+        .thenReturn(mockGitModules);
+  }
+}
diff --git a/lib/BUILD b/lib/BUILD
index f0c0aad..d3ef4b9 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -47,6 +47,13 @@
 )
 
 java_library(
+    name = "jgit-ssh-jsch",
+    data = ["//lib:LICENSE-jgit"],
+    visibility = ["//visibility:public"],
+    exports = ["@jgit//org.eclipse.jgit.ssh.jsch:ssh-jsch"],
+)
+
+java_library(
     name = "jgit-archive",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 95db5cc..0cdad1a 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -16,6 +16,9 @@
 duct-tape
 eddsa
 elasticsearch-rest-client
+flogger
+flogger-log4j-backend
+flogger-system-backend
 httpasyncclient
 httpcore-nio
 j2objc
diff --git a/modules/jgit b/modules/jgit
index 75fccca..8e79d5a 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 75fcccaea39f7a2112886e04a94458d6b7b7c37f
+Subproject commit 8e79d5a290843b929f073a536a0d678fc74382ca
diff --git a/package.json b/package.json
index 926de4e..0f10b62 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,8 @@
   "description": "Gerrit Code Review",
   "dependencies": {},
   "devDependencies": {
-    "@bazel/rollup": "^1.1.0",
-    "@bazel/typescript": "^1.0.1",
+    "@bazel/rollup": "^1.6.1",
+    "@bazel/typescript": "^1.6.1",
     "eslint": "^6.6.0",
     "eslint-config-google": "^0.13.0",
     "eslint-plugin-html": "^6.0.0",
@@ -15,7 +15,7 @@
     "fried-twinkie": "^0.2.2",
     "polymer-cli": "^1.9.11",
     "prettier": "2.0.5",
-    "typescript": "^3.7.4",
+    "typescript": "3.8.2",
     "web-component-tester": "^6.5.1"
   },
   "scripts": {
diff --git a/plugins/BUILD b/plugins/BUILD
index 5f9c142..a071bde 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -71,6 +71,7 @@
     "//lib/jackson:jackson-core",
     "//lib:jgit-servlet",
     "//lib:jgit",
+    "//lib:jgit-ssh-jsch",
     "//lib:jsr305",
     "//lib/log:api",
     "//lib/log:log4j",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index e211fb1..7357ab4 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit e211fb1bd21043e2574c438a687c8f492d538c97
+Subproject commit 7357ab473599d16ae33cc982bbd65472f08c2dd6
diff --git a/plugins/delete-project b/plugins/delete-project
index e345e6e..7671def 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit e345e6e79900a72981e4ad19d37c7fbdcae4818b
+Subproject commit 7671def07882aab89b19eb7496418588ea7375d9
diff --git a/plugins/replication b/plugins/replication
index 9ae2087..5838489 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 9ae20871646a0757979e3bef03aaf4e74af8ff73
+Subproject commit 583848945b150503fb76fb780f53bc5c2b42546c
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 9e7fd9b..e952b92 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 9e7fd9b420ac9a5caa045cf82b566cc0b51c93ad
+Subproject commit e952b920ecbee5225f1098a02d4a39b19aa7e234
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 021ce08..7bca96d 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,6 +1,5 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
 
 package(default_visibility = ["//visibility:public"])
 
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
index b8d54a4..46dfaf2 100644
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
+++ b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {descendedFromClass} from '../../utils/dom-util.js';
+
 export const DomUtilBehavior = {
   /**
    * Are any ancestors of the element (or the element itself) members of the
@@ -28,13 +30,9 @@
    * @return {boolean}
    */
   descendedFromClass(element, className, opt_stopElement) {
-    let isDescendant = element.classList.contains(className);
-    while (!isDescendant && element.parentElement &&
-        (!opt_stopElement || element.parentElement !== opt_stopElement)) {
-      isDescendant = element.classList.contains(className);
-      element = element.parentElement;
-    }
-    return isDescendant;
+    console.warn('DomUtilBehavior is deprecated.' +
+     'Use descendedFromClass from utils directly.');
+    return descendedFromClass(element, className, opt_stopElement);
   },
 };
 
diff --git a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
deleted file mode 100644
index 88c8835..0000000
--- a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior Gerrit.FireBehavior */
-export const FireBehavior = {
-  /**
-   * Dispatches a custom event with an optional detail value.
-   *
-   * @param {string} type Name of event type.
-   * @param {*=} detail Detail value containing event-specific
-   *   payload.
-   * @param {{ bubbles: (boolean|undefined), cancelable: (boolean|undefined),
-   *     composed: (boolean|undefined) }=}
-   *  options Object specifying options.  These may include:
-   *  `bubbles` (boolean, defaults to `true`),
-   *  `cancelable` (boolean, defaults to false), and
-   *  `composed` (boolean, defaults to true).
-   * @return {!Event} The new event that was fired.
-   * @override
-   */
-  fire(type, detail, options) {
-    console.warn('\'fire\' is deprecated, please use dispatchEvent instead!');
-    options = options || {};
-    detail = (detail === null || detail === undefined) ? {} : detail;
-    const event = new Event(type, {
-      bubbles: options.bubbles === undefined ? true : options.bubbles,
-      cancelable: Boolean(options.cancelable),
-      composed: options.composed === undefined ? true: options.composed,
-    });
-    event.detail = detail;
-    this.dispatchEvent(event);
-    return event;
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.FireBehavior = FireBehavior;
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
index 09e3b72..ca31ee8 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
@@ -171,6 +171,7 @@
   TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
   TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
   TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
+  TOGGLE_HIDE_ALL_COMMENT_THREADS: 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
 
   OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
   OPEN_LAST_FILE: 'OPEN_LAST_FILE',
@@ -251,6 +252,8 @@
     'Expand all comment threads');
 _describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
     'Collapse all comment threads');
+_describe(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
+    'Hide/Display all comment threads');
 _describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
 _describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
 _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
@@ -290,6 +293,8 @@
     'Go to selected file');
 _describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
     'Show/hide all inline diffs');
+_describe(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, ShortcutSection.FILE_LIST,
+    'Hide/Display all comment threads');
 _describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
     'Show/hide selected inline diff');
 
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
index c46cf30..e9c16241 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
@@ -82,7 +82,7 @@
     <div id="mainContainer">
       <div class="header">
         <div class="name">
-          <h3>[[_computeSectionName(section.id)]]</h3>
+          <h3 class="heading-3">[[_computeSectionName(section.id)]]</h3>
           <gr-button
             id="editBtn"
             link=""
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
index c5577c2..47657ac 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
@@ -63,9 +63,9 @@
       Loading...
     </div>
     <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title">[[_groupName]]</h1>
+      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
       <div id="form">
-        <h3 id="members">Members</h3>
+        <h3 id="members" class="heading-3">Members</h3>
         <fieldset>
           <span class="value">
             <gr-autocomplete
@@ -112,7 +112,7 @@
             </tbody>
           </table>
         </fieldset>
-        <h3 id="includedGroups">Included Groups</h3>
+        <h3 id="includedGroups" class="heading-3">Included Groups</h3>
         <fieldset>
           <span class="value">
             <gr-autocomplete
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
index 0af87ad..7a843dc 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
@@ -37,18 +37,21 @@
       Loading...
     </div>
     <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title">[[_groupName]]</h1>
-      <h2 id="configurations">General</h2>
+      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
+      <h2 id="configurations" class="heading-2">General</h2>
       <div id="form">
         <fieldset>
-          <h3 id="groupUUID">Group UUID</h3>
+          <h3 id="groupUUID" class="heading-3">Group UUID</h3>
           <fieldset>
             <gr-copy-clipboard
               id="uuid"
               text="[[_getGroupUUID(_groupConfig.id)]]"
             ></gr-copy-clipboard>
           </fieldset>
-          <h3 id="groupName" class$="[[_computeHeaderClass(_rename)]]">
+          <h3
+            id="groupName"
+            class$="heading-3 [[_computeHeaderClass(_rename)]]"
+          >
             Group Name
           </h3>
           <fieldset>
@@ -72,7 +75,10 @@
               >
             </span>
           </fieldset>
-          <h3 id="groupOwner" class$="[[_computeHeaderClass(_owner)]]">
+          <h3
+            id="groupOwner"
+            class$="heading-3 [[_computeHeaderClass(_owner)]]"
+          >
             Owners
           </h3>
           <fieldset>
@@ -99,7 +105,7 @@
               >
             </span>
           </fieldset>
-          <h3 class$="[[_computeHeaderClass(_description)]]">
+          <h3 class$="heading-3 [[_computeHeaderClass(_description)]]">
             Description
           </h3>
           <fieldset>
@@ -123,7 +129,7 @@
               </gr-button>
             </span>
           </fieldset>
-          <h3 id="options" class$="[[_computeHeaderClass(_options)]]">
+          <h3 id="options" class$="heading-3 [[_computeHeaderClass(_options)]]">
             Group Options
           </h3>
           <fieldset>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
index 5f0739a..b46c7c0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
@@ -62,7 +62,10 @@
       Loading...
     </div>
     <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h3 id="inheritsFrom" class$="[[_computeShowInherit(_inheritsFrom)]]">
+      <h3
+        id="inheritsFrom"
+        class$="heading-3 [[_computeShowInherit(_inheritsFrom)]]"
+      >
         <span class="rightsText">Rights Inherit From</span>
         <a
           href$="[[_computeParentHref(_inheritsFrom.name)]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
deleted file mode 100644
index d161423..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo-command_html.js';
-
-/** @extends PolymerElement */
-class GrRepoCommand extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo-command'; }
-
-  static get properties() {
-    return {
-      title: String,
-      disabled: Boolean,
-      tooltip: String,
-    };
-  }
-
-  /**
-   * Fired when command button is tapped.
-   *
-   * @event command-tap
-   */
-
-  _onCommandTap() {
-    this.dispatchEvent(
-        new CustomEvent('command-tap', {bubbles: true, composed: true}));
-  }
-}
-
-customElements.define(GrRepoCommand.is, GrRepoCommand);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
deleted file mode 100644
index a73f071..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ /dev/null
@@ -1,53 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-command</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-command></gr-repo-command>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-command.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-repo-command tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('dispatched command-tap on button tap', done => {
-    element.addEventListener('command-tap', () => {
-      done();
-    });
-    MockInteractions.tap(
-        dom(element.root).querySelector('gr-button'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 03a2bd3..4ab1b98 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -24,7 +24,6 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-create-change-dialog/gr-create-change-dialog.js';
-import '../gr-repo-command/gr-repo-command.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
@@ -60,6 +59,10 @@
       /** @type {?} */
       _repoConfig: Object,
       _canCreate: Boolean,
+      // states
+      _creatingChange: Boolean,
+      _editingConfig: Boolean,
+      _runningGC: Boolean,
     };
   }
 
@@ -102,13 +105,17 @@
   }
 
   _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() {
@@ -116,7 +123,11 @@
   }
 
   _handleCreateChange() {
-    this.$.createNewChangeModal.handleCreateChange();
+    this._creatingChange = true;
+    this.$.createNewChangeModal.handleCreateChange()
+        .finally(() => {
+          this._creatingChange = false;
+        });
     this._handleCloseCreateChange();
   }
 
@@ -125,6 +136,7 @@
   }
 
   _handleEditRepoConfig() {
+    this._editingConfig = true;
     return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
         EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
       const message = change ?
@@ -136,7 +148,10 @@
 
       GerritNav.navigateToRelativeUrl(GerritNav.getEditUrlForDiff(
           change, CONFIG_PATH, INITIAL_PATCHSET));
-    });
+    })
+        .finally(() => {
+          this._editingConfig = false;
+        });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
index b27c36b..3ae5b29 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
@@ -24,34 +24,41 @@
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
   <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    #form gr-button {
+      margin-bottom: var(--spacing-xxl);
+    }
   </style>
   <main class="gr-form-styles read-only">
-    <h1 id="Title">Repository Commands</h1>
+    <h1 id="Title" class="heading-1">Repository Commands</h1>
     <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
       Loading...
     </div>
     <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h2 id="options">Command</h2>
+      <h2 id="options" class="heading-2">Command</h2>
       <div id="form">
-        <gr-repo-command
-          title="Create change"
-          on-command-tap="_createNewChange"
-        >
-        </gr-repo-command>
-        <gr-repo-command
+        <h3>Create change</h3>
+        <gr-button loading="[[_creatingChange]]" on-click="_createNewChange">
+          Create change
+        </gr-button>
+        <h3>Edit repo config</h3>
+        <gr-button
           id="editRepoConfig"
-          title="Edit repo config"
-          on-command-tap="_handleEditRepoConfig"
+          loading="[[_editingConfig]]"
+          on-click="_handleEditRepoConfig"
         >
-        </gr-repo-command>
-        <gr-repo-command
-          title="[[_repoConfig.actions.gc.label]]"
-          tooltip="[[_repoConfig.actions.gc.title]]"
-          hidden$="[[!_repoConfig.actions.gc.enabled]]"
-          on-command-tap="_handleRunningGC"
+          Edit repo config
+        </gr-button>
+        <h3 hidden="[[!_repoConfig.actions.gc.enabled]]">
+          [[_repoConfig.actions.gc.label]]
+        </h3>
+        <gr-button
+          hidden="[[!_repoConfig.actions.gc.enabled]]"
+          title="[[_repoConfig.actions.gc.title]]"
+          loading="[[_runningGC]]"
+          on-click="_handleRunningGC"
         >
-        </gr-repo-command>
+          [[_repoConfig.actions.gc.label]]
+        </gr-button>
         <gr-endpoint-decorator name="repo-command">
           <gr-endpoint-param name="config" value="[[_repoConfig]]">
           </gr-endpoint-param>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
index db2bfcf..a52ab92 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
@@ -99,8 +99,8 @@
     test('successful creation of change', () => {
       const change = {_number: '1'};
       createChangeStub.returns(Promise.resolve(change));
-      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
-          .querySelector('gr-button'));
+      MockInteractions.tap(element.$.editRepoConfig);
+      assert.isTrue(element.$.editRepoConfig.loading);
       return handleSpy.lastCall.returnValue.then(() => {
         flushAsynchronousOperations();
 
@@ -110,13 +110,14 @@
         assert.isTrue(urlStub.called);
         assert.deepEqual(urlStub.lastCall.args,
             [change, 'project.config', 1]);
+        assert.isFalse(element.$.editRepoConfig.loading);
       });
     });
 
     test('unsuccessful creation of change', () => {
       createChangeStub.returns(Promise.resolve(null));
-      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
-          .querySelector('gr-button'));
+      MockInteractions.tap(element.$.editRepoConfig);
+      assert.isTrue(element.$.editRepoConfig.loading);
       return handleSpy.lastCall.returnValue.then(() => {
         flushAsynchronousOperations();
 
@@ -124,6 +125,7 @@
         assert.equal(alertStub.lastCall.args[0].detail.message,
             'Failed to create change.');
         assert.isFalse(urlStub.called);
+        assert.isFalse(element.$.editRepoConfig.loading);
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
index de36e73..ea8a8b9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
@@ -50,7 +50,7 @@
       /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
     </style>
     <div class="info">
-      <h1 id="Title" class$="name">
+      <h1 id="Title" class="heading-1">
         [[repo]]
         <hr />
       </h1>
@@ -63,7 +63,7 @@
     </div>
     <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
       <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
-        <h2 id="download">Download</h2>
+        <h2 id="download" class="heading-2">Download</h2>
         <fieldset>
           <gr-download-commands
             id="downloadCommands"
@@ -73,12 +73,15 @@
           ></gr-download-commands>
         </fieldset>
       </div>
-      <h2 id="configurations" class$="[[_computeHeaderClass(_configChanged)]]">
+      <h2
+        id="configurations"
+        class$="heading-2 [[_computeHeaderClass(_configChanged)]]"
+      >
         Configurations
       </h2>
       <div id="form">
         <fieldset>
-          <h3 id="Description">Description</h3>
+          <h3 id="Description" class="heading-3">Description</h3>
           <fieldset>
             <iron-autogrow-textarea
               id="descriptionInput"
@@ -89,7 +92,7 @@
               disabled$="[[_readOnly]]"
             ></iron-autogrow-textarea>
           </fieldset>
-          <h3 id="Options">Repository Options</h3>
+          <h3 id="Options" class="heading-3">Repository Options</h3>
           <fieldset id="options">
             <section>
               <span class="title">State</span>
@@ -362,7 +365,7 @@
               </span>
             </section>
           </fieldset>
-          <h3 id="Options">Contributor Agreements</h3>
+          <h3 id="Options" class="heading-3">Contributor Agreements</h3>
           <fieldset id="agreements">
             <section>
               <span class="title">
@@ -407,7 +410,7 @@
             class$="pluginConfig [[_computeHideClass(_pluginData)]]"
             on-plugin-config-changed="_handlePluginConfigChanged"
           >
-            <h3>Plugins</h3>
+            <h3 class="heading-3">Plugins</h3>
             <template is="dom-repeat" items="[[_pluginData]]" as="data">
               <gr-repo-plugin-config
                 plugin-data="[[data]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
index f29c8bf..4e826aa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
@@ -83,10 +83,10 @@
       line-height: var(--line-height-mono);
     }
     .u-green {
-      color: var(--vote-text-color-recommended);
+      color: var(--positive-green-text-color);
     }
     .u-red {
-      color: var(--vote-text-color-disliked);
+      color: var(--negative-red-text-color);
     }
     .u-gray-background {
       background-color: var(--table-header-background-color);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
index 4add1da..b322f38 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
@@ -86,14 +86,15 @@
         href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
         class$="[[_computePrevArrowClass(_offset)]]"
       >
-        <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
       </a>
       <a
         id="nextArrow"
         href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
         class$="[[_computeNextArrowClass(_changes)]]"
       >
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
+        </iron-icon>
       </a>
     </nav>
   </div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
index 4a357af..cc6223d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
@@ -48,12 +48,6 @@
       padding-top: var(--spacing-xl);
       vertical-align: top;
     }
-    #help h1 {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
     #help p {
       margin-bottom: var(--spacing-m);
       max-width: 35em;
@@ -73,7 +67,7 @@
     </p>
   </div>
   <div id="help">
-    <h1>Push your first change for code review</h1>
+    <h2 class="heading-3">Push your first change for code review</h2>
     <p>
       Pushing a change for review is easy, but a little different from other git
       code review tools. Click on the \`Create Change' button and follow the
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
index f6fb1d0..75af51e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
@@ -24,7 +24,7 @@
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
   <div class="info">
-    <h1 class$="name">
+    <h1 class="heading-1">
       [[repo]]
       <hr />
     </h1>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
index 5a5d590..8207284 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
@@ -21,12 +21,6 @@
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
   <style include="dashboard-header-styles">
-    .name {
-      display: inline-block;
-    }
-    .name hr {
-      width: 100%;
-    }
     .status.hide,
     .name.hide,
     .dashboardLink.hide {
@@ -39,7 +33,7 @@
     aria-label="Account avatar"
   ></gr-avatar>
   <div class="info">
-    <h1 class="name">
+    <h1 class="heading-1">
       [[_computeDetail(_accountDetails, 'name')]]
     </h1>
     <hr />
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 03a64a5..8dc8058 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -87,12 +87,15 @@
   PRIVATE: 'private',
   PRIVATE_DELETE: 'private.delete',
   PUBLISH_EDIT: 'publishEdit',
+  REBASE: 'rebase',
   REBASE_EDIT: 'rebaseEdit',
+  READY: 'ready',
   RESTORE: 'restore',
   REVERT: 'revert',
   REVERT_SUBMISSION: 'revert_submission',
   REVIEWED: 'reviewed',
   STOP_EDIT: 'stopEdit',
+  SUBMIT: 'submit',
   UNIGNORE: 'unignore',
   UNREVIEWED: 'unreviewed',
   WIP: 'wip',
@@ -205,6 +208,7 @@
   ChangeActions.DELETE_EDIT,
   ChangeActions.EDIT,
   ChangeActions.PUBLISH_EDIT,
+  ChangeActions.READY,
   ChangeActions.REBASE_EDIT,
   ChangeActions.RESTORE,
   ChangeActions.REVERT,
@@ -293,6 +297,7 @@
         type: Array,
         value() {
           return [
+            ChangeActions.READY,
             RevisionActions.SUBMIT,
           ];
         },
@@ -1353,6 +1358,8 @@
         case ChangeActions.DELETE_EDIT:
         case ChangeActions.PUBLISH_EDIT:
         case ChangeActions.REBASE_EDIT:
+        case ChangeActions.REBASE:
+        case ChangeActions.SUBMIT:
           GerritNav.navigateToChange(this.change);
           break;
         case ChangeActions.REVERT_SUBMISSION:
@@ -1539,6 +1546,12 @@
           if (ACTIONS_WITH_ICONS.has(action.__key)) {
             action.icon = action.__key;
           }
+          // TODO(brohlfs): Temporary hack until change 269573 is live in all
+          // backends.
+          if (action.__key === ChangeActions.READY) {
+            action.label = 'Mark as Active';
+          }
+          // End of hack
           return action;
         })
         .filter(action => !this._shouldSkipAction(action));
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
index 916a56f..200ffc9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
@@ -137,7 +137,6 @@
     <gr-dropdown
       id="moreActions"
       link=""
-      tabindex="0"
       vertical-offset="32"
       horizontal-align="right"
       on-tap-item="_handleOverflowItemTap"
@@ -145,7 +144,8 @@
       disabled-ids="[[_disabledMenuActions]]"
       items="[[_menuActions]]"
     >
-      <iron-icon icon="gr-icons:more-vert"></iron-icon>
+      <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+      </iron-icon>
       <span id="moreMessage">More</span>
     </gr-dropdown>
   </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index a763250..cda53c5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -415,6 +415,17 @@
       });
     });
 
+    test('rebase change calls navigateToChange', done => {
+      const navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange');
+      sandbox.stub(element.$.restAPI, 'getResponseObject').returns(
+          Promise.resolve({}));
+      element._handleResponse({__key: 'rebase'}, {});
+      flush(() => {
+        assert.isTrue(navigateToChangeStub.called);
+        done();
+      });
+    });
+
     test(`rebase dialog gets recent changes each time it's opened`, done => {
       const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
           'fetchRecentChanges').returns(Promise.resolve([]));
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
index 1b18412..ca176be 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
@@ -74,10 +74,10 @@
       color: #ffa62f;
     }
     .icon.invalid {
-      color: var(--vote-text-color-disliked);
+      color: var(--negative-red-text-color);
     }
     .icon.trusted {
-      color: var(--vote-text-color-recommended);
+      color: var(--positive-green-text-color);
     }
     .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
       --arrow-color: #ffa62f;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index 78627a7..d2d6037 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -104,7 +104,7 @@
   }
 
   _computeRequirementIcon(requirementStatus) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
+    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
   }
 
   _computeLabels(labelsRecord) {
@@ -132,7 +132,7 @@
   _computeLabelIcon(labelInfo) {
     if (labelInfo.approved) { return 'gr-icons:check'; }
     if (labelInfo.rejected) { return 'gr-icons:close'; }
-    return 'gr-icons:hourglass';
+    return 'gr-icons:schedule';
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
index 0da31de..657915b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
@@ -31,10 +31,10 @@
       line-height: var(--line-height-mono);
     }
     .approved.status {
-      color: var(--vote-text-color-recommended);
+      color: var(--positive-green-text-color);
     }
     .rejected.status {
-      color: var(--vote-text-color-disliked);
+      color: var(--negative-red-text-color);
     }
     iron-icon {
       color: inherit;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
index e100f91..276e821 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
@@ -51,13 +51,13 @@
 
     assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
     assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:hourglass');
+        'gr-icons:schedule');
   });
 
   test('label computed fields', () => {
     assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
     assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
+    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
 
     assert.equal(element._computeLabelClass({approved: []}), 'approved');
     assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
@@ -90,7 +90,7 @@
     assert.equal(element._requiredLabels.length, 1);
 
     assert.equal(element._optionalLabels[0].label, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
+    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
     assert.equal(element._optionalLabels[0].style, '');
     assert.ok(element._optionalLabels[0].labelInfo);
   });
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 05a3ae5..799adde 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
@@ -57,7 +57,7 @@
 import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GrEditConstants} from '../../edit/gr-edit-constants.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {util} from '../../../scripts/util.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
@@ -228,7 +228,6 @@
         type: Boolean,
         computed: '_computeCanStartReview(_change)',
       },
-      _comments: Object,
       /** @type {?} */
       _change: {
         type: Object,
@@ -309,7 +308,7 @@
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts.*)',
+        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
       },
       _selectedPatchSet: String,
       _shownFileCount: Number,
@@ -921,23 +920,6 @@
     this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
   }
 
-  _handleReadyTap(e) {
-    e.preventDefault();
-    const button = e && e.target;
-    if (button) {
-      button.loading = true;
-    }
-    return this.$.restAPI.startReview(this._changeNum)
-        .then(result => {
-          this._reload(result);
-        })
-        .finally(() => {
-          if (button) {
-            button.loading = false;
-          }
-        });
-  }
-
   _handleOpenDiffPrefs() {
     this.$.fileList.openDiffPrefs();
   }
@@ -1051,10 +1033,7 @@
         (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
         (this._patchRange.patchNum !== value.patchNum ||
         this._patchRange.basePatchNum !== value.basePatchNum);
-
-    if (this._changeNum !== value.changeNum) {
-      this._initialLoadComplete = false;
-    }
+    const changeChanged = this._changeNum !== value.changeNum;
 
     const patchRange = {
       patchNum: value.patchNum,
@@ -1066,7 +1045,7 @@
 
     // If the change has already been loaded and the parameter change is only
     // in the patch range, then don't do a full reload.
-    if (this._initialLoadComplete && patchChanged) {
+    if (!changeChanged && patchChanged) {
       if (patchRange.patchNum == null) {
         patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
       }
@@ -1076,6 +1055,7 @@
       return;
     }
 
+    this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
     this.$.relatedChanges.clear();
 
@@ -1370,11 +1350,14 @@
     return result;
   }
 
-  _computeReplyButtonLabel(changeRecord) {
+  _computeReplyButtonLabel(changeRecord, canStartReview) {
     // Polymer 2: check for undefined
-    if ([changeRecord].some(arg => arg === undefined)) {
+    if ([changeRecord, canStartReview].some(arg => arg === undefined)) {
       return 'Reply';
     }
+    if (canStartReview) {
+      return 'Start Review';
+    }
 
     const drafts = (changeRecord && changeRecord.base) || {};
     const draftCount = Object.keys(drafts)
@@ -1515,15 +1498,8 @@
     });
   }
 
-  _handleReloadChange(e) {
-    return this._reload().then(() => {
-      // If the change was rebased or submitted, we need to reload the page
-      // with the latest patch.
-      const action = e.detail.action;
-      if (action === 'rebase' || action === 'submit') {
-        GerritNav.navigateToChange(this._change);
-      }
-    });
+  _handleReloadChange() {
+    return this._reload();
   }
 
   _handleGetChangeDetailError(response) {
@@ -1712,6 +1688,13 @@
    * (comments, robot comments, draft comments) is requested.
    */
   _reloadComments() {
+    // We are resetting all comment related properties, because we want to avoid
+    // a new change being loaded and then paired with outdated comments.
+    this._changeComments = undefined;
+    this._commentThreads = undefined;
+    this._diffDrafts = undefined;
+    this._draftCommentThreads = undefined;
+    this._robotCommentThreads = undefined;
     return this.$.commentAPI.loadAll(this._changeNum)
         .then(comments => this._recomputeComments(comments));
   }
@@ -2020,7 +2003,7 @@
   _computeShowRelatedToggle() {
     // Make sure the max height has been applied, since there is now content
     // to populate.
-    if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
+    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
       this._updateRelatedChangeMaxHeight();
     }
     // Prevents showMore from showing when click on related change, since the
@@ -2129,7 +2112,8 @@
 
   _handleFileActionTap(e) {
     e.preventDefault();
-    const controls = this.$.fileListHeader.$.editControls;
+    const controls = this.$.fileListHeader
+        .shadowRoot.querySelector('#editControls');
     const path = e.detail.path;
     switch (e.detail.action) {
       case GrEditConstants.Actions.DELETE.id:
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
index 7548017..f5a0463 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
@@ -172,9 +172,6 @@
       margin: var(--spacing-l) 0;
       padding: 0 var(--spacing-l);
     }
-    #startReviewBtn {
-      margin-left: var(--spacing-s);
-    }
     .collapseToggleContainer {
       display: flex;
       margin-bottom: 8px;
@@ -446,20 +443,11 @@
                   title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
                         ShortcutSection.ACTIONS)]]"
                   hidden$="[[!_loggedIn]]"
-                  primary$="[[!_canStartReview]]"
+                  primary=""
                   disabled="[[_replyDisabled]]"
                   on-click="_handleReplyTap"
                   >[[_replyButtonLabel]]</gr-button
                 >
-                <gr-button
-                  id="startReviewBtn"
-                  class="startReview"
-                  title="Switches change state from 'Work in Progress' to 'Active'."
-                  hidden="[[!_canStartReview]]"
-                  primary$="[[_canStartReview]]"
-                  on-click="_handleReadyTap"
-                  >Start review</gr-button
-                >
               </div>
               <div id="commitMessage" class="commitMessage">
                 <gr-editable-content
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 6667ddb..faa81b6 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
@@ -24,7 +24,7 @@
 import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
 import {GrEditConstants} from '../../edit/gr-edit-constants.js';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {util} from '../../../scripts/util.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
@@ -320,7 +320,7 @@
   });
 
   const getCustomCssValue =
-      cssParam => util.getComputedStyleValue(cssParam, element);
+      cssParam => getComputedStyleValue(cssParam, element);
 
   test('_handleMessageAnchorTap', () => {
     element._changeNum = '1';
@@ -1037,7 +1037,7 @@
     const getLabel = element._computeReplyButtonLabel;
 
     assert.equal(getLabel(null, false), 'Reply');
-    assert.equal(getLabel(null, true), 'Reply');
+    assert.equal(getLabel(null, true), 'Start Review');
 
     const changeRecord = {base: null};
     assert.equal(getLabel(changeRecord, false), 'Reply');
@@ -1052,10 +1052,6 @@
     assert.equal(getLabel(changeRecord, false), 'Reply (3)');
   });
 
-  test('start review button when owner of WIP change', () => {
-    assert.equal(element.$.startReviewBtn.innerHTML, 'Start review');
-  });
-
   test('comment events properly update diff drafts', () => {
     element._patchRange = {
       basePatchNum: 'PARENT',
@@ -1218,20 +1214,6 @@
     assert.isTrue(collapseStub.calledTwice);
   });
 
-  test('related changes are updated and new patch selected after rebase',
-      done => {
-        element._changeNum = '42';
-        sandbox.stub(element, 'computeLatestPatchNum', () => 1);
-        sandbox.stub(element, '_reload',
-            () => Promise.resolve());
-        const e = {detail: {action: 'rebase'}};
-        element._handleReloadChange(e).then(() => {
-          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-              element._change));
-          done();
-        });
-      });
-
   test('related changes are not updated after other action', done => {
     sandbox.stub(element, '_reload', () => Promise.resolve());
     sandbox.stub(element.$.relatedChanges, 'reload');
@@ -2000,7 +1982,10 @@
     };
     const fileList = element.$.fileList;
     const Actions = GrEditConstants.Actions;
-    const controls = element.$.fileListHeader.$.editControls;
+    element.$.fileListHeader.editMode = true;
+    flushAsynchronousOperations();
+    const controls = element.$.fileListHeader
+        .shadowRoot.querySelector('#editControls');
     sandbox.stub(controls, 'openDeleteDialog');
     sandbox.stub(controls, 'openRenameDialog');
     sandbox.stub(controls, 'openRestoreDialog');
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
index 9569c03..0446e4e 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
@@ -56,16 +56,12 @@
     .archives a:last-of-type {
       margin-right: 0;
     }
-    .title {
-      flex: 1;
-      font-weight: var(--font-weight-bold);
-    }
     .hidden {
       display: none;
     }
   </style>
   <section>
-    <h3 class="title">
+    <h3 class="heading-3">
       Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
     </h3>
   </section>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
index 10b8606..bb04114 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
@@ -188,14 +188,16 @@
       </div>
     </div>
     <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
-      <span class="showOnEdit flexContainer">
-        <gr-edit-controls
-          id="editControls"
-          patch-num="[[patchNum]]"
-          change="[[change]]"
-        ></gr-edit-controls>
-        <span class="separator"></span>
-      </span>
+      <template is="dom-if" if="[[editMode]]">
+        <span class="showOnEdit flexContainer">
+          <gr-edit-controls
+            id="editControls"
+            patch-num="[[patchNum]]"
+            change="[[change]]"
+          ></gr-edit-controls>
+          <span class="separator"></span>
+        </span>
+      </template>
       <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
         <gr-button link="" class="upload" on-click="_handleUploadTap"
           >Update Change</gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index 19362d5..2a5a302 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -299,13 +299,22 @@
     });
 
     test('edit-controls visibility', () => {
+      element.editMode = false;
+      flushAsynchronousOperations();
+      // on the first render, when editMode is false, editControls are not
+      // in the DOM to reduce size of DOM and make first render faster.
+      assert.isNull(element.shadowRoot
+          .querySelector('#editControls'));
+
       element.editMode = true;
       flushAsynchronousOperations();
-      assert.isTrue(isVisible(element.$.editControls.parentElement));
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('#editControls').parentElement));
 
       element.editMode = false;
       flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$.editControls.parentElement));
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('#editControls').parentElement));
     });
 
     test('_computeUploadHelpContainerClass', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 12b5aad..fbae39f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -70,6 +70,8 @@
   U: 'Unchanged',
 };
 
+const FILE_ROW_CLASS = 'file-row';
+
 /**
  * Type for FileInfo
  *
@@ -225,6 +227,11 @@
         computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
                 '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
       },
+      _showPrependedDynamicColumns: {
+        type: Boolean,
+        computed: '_computeShowPrependedDynamicColumns(' +
+        '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+      },
       /** @type {Array<string>} */
       _dynamicHeaderEndpoints: {
         type: Array,
@@ -237,6 +244,14 @@
       _dynamicSummaryEndpoints: {
         type: Array,
       },
+      /** @type {Array<string>} */
+      _dynamicPrependedHeaderEndpoints: {
+        type: Array,
+      },
+      /** @type {Array<string>} */
+      _dynamicPrependedContentEndpoints: {
+        type: Array,
+      },
     };
   }
 
@@ -260,6 +275,8 @@
       [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
       [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
       [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [this.Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
       [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
       [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
       [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
@@ -295,18 +312,27 @@
   attached() {
     super.attached();
     pluginLoader.awaitPluginsLoaded().then(() => {
-      this._dynamicHeaderEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-header');
-      this._dynamicContentEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-content');
-      this._dynamicSummaryEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-summary');
+      this._dynamicHeaderEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-header');
+      this._dynamicContentEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-content');
+      this._dynamicPrependedHeaderEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-header-prepend');
+      this._dynamicPrependedContentEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-content-prepend');
+      this._dynamicSummaryEndpoints = pluginEndpoints
+          .getDynamicEndpoints('change-view-file-list-summary');
 
       if (this._dynamicHeaderEndpoints.length !==
           this._dynamicContentEndpoints.length) {
         console.warn(
             'Different number of dynamic file-list header and content.');
       }
+      if (this._dynamicPrependedHeaderEndpoints.length !==
+        this._dynamicPrependedContentEndpoints.length) {
+        console.warn(
+            'Different number of dynamic file-list header and content.');
+      }
       if (this._dynamicHeaderEndpoints.length !==
           this._dynamicSummaryEndpoints.length) {
         console.warn(
@@ -490,6 +516,9 @@
    * @return {string}
    */
   _computeCommentsString(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
+      return '';
+    }
     const unresolvedCount =
         changeComments.computeUnresolvedNum({
           patchNum: patchRange.basePatchNum,
@@ -529,6 +558,9 @@
    * @return {string}
    */
   _computeDraftsString(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
+      return '';
+    }
     const draftCount =
         changeComments.computeDraftCount({
           patchNum: patchRange.basePatchNum,
@@ -550,6 +582,9 @@
    * @return {string}
    */
   _computeDraftsStringMobile(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
+      return '';
+    }
     const draftCount =
         changeComments.computeDraftCount({
           patchNum: patchRange.basePatchNum,
@@ -571,6 +606,9 @@
    * @return {string}
    */
   _computeCommentsStringMobile(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].some(arg => arg === undefined)) {
+      return '';
+    }
     const commentCount =
         changeComments.computeCommentCount({
           patchNum: patchRange.basePatchNum,
@@ -642,29 +680,55 @@
   }
 
   /**
+   * Returns true if the event e is a click on an element.
+   *
+   * The click is: mouse click or pressing Enter or Space key
+   * P.S> Screen readers sends click event as well
+   */
+  _isClickEvent(e) {
+    if (e.type === 'click') {
+      return true;
+    }
+    const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' ');
+    return e.type === 'keydown' && isSpaceOrEnter;
+  }
+
+  _fileActionClick(e, fileAction) {
+    if (this._isClickEvent(e)) {
+      const fileRow = this._getFileRowFromEvent(e);
+      if (!fileRow) {
+        return;
+      }
+      // Prevent default actions (e.g. scrolling for space key)
+      e.preventDefault();
+      // Prevent _handleFileListClick handler call
+      e.stopPropagation();
+      this.$.fileCursor.setCursor(fileRow.element);
+      fileAction(fileRow.file);
+    }
+  }
+
+  _reviewedClick(e) {
+    this._fileActionClick(e,
+        file => this._reviewFile(file.path));
+  }
+
+  _expandedClick(e) {
+    this._fileActionClick(e,
+        file => this._toggleFileExpanded(file));
+  }
+
+  /**
    * Handle all events from the file list dom-repeat so event handleers don't
    * have to get registered for potentially very long lists.
    */
   _handleFileListClick(e) {
-    // Traverse upwards to find the row element if the target is not the row.
-    let row = e.target;
-    while (!row.classList.contains('row') && row.parentElement) {
-      row = row.parentElement;
-    }
-
-    // No action needed for item without a valid file
-    if (!row.dataset.file) {
+    const fileRow = this._getFileRowFromEvent(e);
+    if (!fileRow) {
       return;
     }
-
-    const file = JSON.parse(row.dataset.file);
+    const file = fileRow.file;
     const path = file.path;
-    // Handle checkbox mark as reviewed.
-    if (e.target.classList.contains('markReviewed')) {
-      e.preventDefault();
-      return this._reviewFile(path);
-    }
-
     // If a path cannot be interpreted from the click target (meaning it's not
     // somewhere in the row, e.g. diff content) or if the user clicked the
     // link, defer to the native behavior.
@@ -674,9 +738,28 @@
     if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
 
     e.preventDefault();
+    this.$.fileCursor.setCursor(fileRow.element);
     this._toggleFileExpanded(file);
   }
 
+  _getFileRowFromEvent(e) {
+    // Traverse upwards to find the row element if the target is not the row.
+    let row = e.target;
+    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
+      row = row.parentElement;
+    }
+
+    // No action needed for item without a valid file
+    if (!row.dataset.file) {
+      return null;
+    }
+
+    return {
+      file: JSON.parse(row.dataset.file),
+      element: row,
+    };
+  }
+
   /**
    * Generates file range from file info object.
    *
@@ -727,6 +810,15 @@
     this._toggleInlineDiffs();
   }
 
+  _handleToggleHideAllCommentThreads(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.toggleClass('hideComments');
+  }
+
   _handleCursorNext(e) {
     if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
       return;
@@ -1032,7 +1124,7 @@
     if (this._files && this._files.length > 0) {
       flush();
       this.$.fileCursor.stops = Array.from(
-          dom(this.root).querySelectorAll('.file-row'));
+          dom(this.root).querySelectorAll(`.${FILE_ROW_CLASS}`));
       this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
     }
   }
@@ -1095,10 +1187,29 @@
       FileStatus[statusCode] : 'Status Unknown';
   }
 
+  /**
+   * Converts any boolean-like variable to the string 'true' or 'false'
+   *
+   * This method is useful when you bind aria-checked attribute to a boolean
+   * value. The aria-checked attribute is string attribute. Binding directly
+   * to boolean variable causes problem on gerrit-CI.
+   *
+   * @param {object} val
+   * @return {string} 'true' if val is true-like, otherwise false
+   */
+  _booleanToString(val) {
+    return val ? 'true' : 'false';
+  }
+
   _isFileExpanded(path, expandedFilesRecord) {
     return expandedFilesRecord.base.some(f => f.path === path);
   }
 
+  _isFileExpandedStr(path, expandedFilesRecord) {
+    return this._booleanToString(
+        this._isFileExpanded(path, expandedFilesRecord));
+  }
+
   _computeExpandedFiles(expandedCount, totalCount) {
     if (expandedCount === 0) {
       return GrFileListConstants.FilesExpandedState.NONE;
@@ -1144,7 +1255,7 @@
     }
 
     this._updateDiffCursor();
-    this.$.diffCursor.handleDiffUpdate();
+    this.$.diffCursor.reInitAndUpdateStops();
   }
 
   _clearCollapsedDiffs(collapsedDiffs) {
@@ -1168,6 +1279,14 @@
   _renderInOrder(files, diffElements, initialCount) {
     let iter = 0;
 
+    for (const file of files) {
+      const path = file.path;
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (diffElem) {
+        diffElem.prefetchDiff();
+      }
+    }
+
     return (new Promise(resolve => {
       this.dispatchEvent(new CustomEvent('reload-drafts', {
         detail: {resolve},
@@ -1205,6 +1324,10 @@
        * This also however results in the fact that the cursor does not auto
        * focus on the first diff chunk on a small screen. This is however, a use
        * case we are willing to not support for now.
+
+       * Using handleDiffUpdate resulted in diffCursor.row being set which
+       * prevented the issue of scrolling to top when we expand the second
+       * file individually.
        */
       this.$.diffCursor.reInitAndUpdateStops();
     }));
@@ -1432,11 +1555,23 @@
   _computeShowDynamicColumns(
       headerEndpoints, contentEndpoints, summaryEndpoints) {
     return headerEndpoints && contentEndpoints && summaryEndpoints &&
+           headerEndpoints.length &&
            headerEndpoints.length === contentEndpoints.length &&
            headerEndpoints.length === summaryEndpoints.length;
   }
 
   /**
+   * Shows registered dynamic prepended columns iff the 'header', 'content'
+   * endpoints are registered the exact same number of times.
+   */
+  _computeShowPrependedDynamicColumns(
+      headerEndpoints, contentEndpoints) {
+    return headerEndpoints && contentEndpoints &&
+           headerEndpoints.length &&
+           headerEndpoints.length === contentEndpoints.length;
+  }
+
+  /**
    * Returns true if none of the inline diffs have been expanded.
    *
    * @return {boolean}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
index 600b0bc..a2714d9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
@@ -28,6 +28,20 @@
       min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
       padding: var(--spacing-xs) var(--spacing-l);
     }
+    /* The class defines a content visible only to screen readers */
+    .noCommentsScreenReaderText {
+      opacity: 0;
+      max-width: 1px;
+      overflow: hidden;
+      display: none;
+    }
+    div[role='gridcell']
+      > div.comments
+      > span:empty
+      + span:empty
+      + span.noCommentsScreenReaderText {
+      display: inline;
+    }
     :host(.loading) .row {
       opacity: 0.5;
     }
@@ -75,6 +89,7 @@
       font-size: var(--font-size-small);
       background-color: var(--dark-add-highlight-color);
     }
+    .status.invisible,
     .status.M {
       display: none;
     }
@@ -151,10 +166,10 @@
       min-width: 3.5em;
     }
     .added {
-      color: var(--vote-text-color-recommended);
+      color: var(--positive-green-text-color);
     }
     .removed {
-      color: var(--vote-text-color-disliked);
+      color: var(--negative-red-text-color);
       text-align: left;
       min-width: 4em;
       padding-left: var(--spacing-s);
@@ -163,6 +178,9 @@
       color: #c62828;
       font-weight: var(--font-weight-bold);
     }
+    .show-hide-icon:focus {
+      outline: none;
+    }
     .show-hide {
       margin-left: var(--spacing-s);
       width: 1.9em;
@@ -199,27 +217,24 @@
       margin-left: var(--spacing-xxl);
       width: 15em;
     }
-    .reviewed label {
+    .reviewedSwitch {
       color: var(--link-color);
       opacity: 0;
       justify-content: flex-end;
       width: 100%;
     }
-    .reviewed label:hover {
+    .reviewedSwitch:hover {
       cursor: pointer;
       opacity: 100;
     }
     .row:focus {
       outline: none;
     }
-    .row:hover .reviewed label,
-    .row:focus .reviewed label,
-    .row.expanded .reviewed label {
+    .row:hover .reviewedSwitch,
+    .row:focus-within .reviewedSwitch,
+    .row.expanded .reviewedSwitch {
       opacity: 100;
     }
-    .reviewed input {
-      display: none;
-    }
     .reviewedLabel {
       color: var(--deemphasized-text-color);
       margin-right: var(--spacing-l);
@@ -232,6 +247,9 @@
     .editFileControls {
       width: 7em;
     }
+    .markReviewed:focus {
+      outline: none;
+    }
     .markReviewed,
     .pathLink {
       display: inline-block;
@@ -254,10 +272,8 @@
         padding: 0px;
       }
     }
-    .pathLink:hover gr-copy-clipboard,
-    .pathLink:focus gr-copy-clipboard,
-    .oldPath:focus gr-copy-clipboard,
-    .oldPath:hover gr-copy-clipboard {
+    .row:focus-within gr-copy-clipboard,
+    .row:hover gr-copy-clipboard {
       visibility: visible;
     }
 
@@ -294,27 +310,57 @@
         display: none;
       }
     }
+    :host(.hideComments) {
+      --gr-comment-thread-display: none;
+    }
   </style>
-  <div id="container" on-click="_handleFileListClick">
-    <div class="header-row row">
-      <div class="path">File</div>
-      <div class="comments">Comments</div>
-      <div class="sizeBars">Size</div>
-      <div class="header-stats">Delta</div>
+  <div
+    id="container"
+    on-click="_handleFileListClick"
+    role="grid"
+    aria-label="Files list"
+  >
+    <div class="header-row row" role="row">
+      <!-- endpoint: change-view-file-list-header-prepend -->
+      <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+        <template
+          is="dom-repeat"
+          items="[[_dynamicPrependedHeaderEndpoints]]"
+          as="headerEndpoint"
+        >
+          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+            <gr-endpoint-param
+              name="change"
+              value="[[change]]"
+            ></gr-endpoint-param>
+            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </template>
+      </template>
+      <div class="path" role="columnheader">File</div>
+      <div class="comments" role="columnheader">Comments</div>
+      <div class="sizeBars" role="columnheader">Size</div>
+      <div class="header-stats" role="columnheader">Delta</div>
+      <!-- endpoint: change-view-file-list-header -->
       <template is="dom-if" if="[[_showDynamicColumns]]">
         <template
           is="dom-repeat"
           items="[[_dynamicHeaderEndpoints]]"
           as="headerEndpoint"
         >
-          <gr-endpoint-decorator name$="[[headerEndpoint]]">
+          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
           </gr-endpoint-decorator>
         </template>
       </template>
       <!-- Empty div here exists to keep spacing in sync with file rows. -->
-      <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-      <div class="editFileControls showOnEdit"></div>
-      <div class="show-hide"></div>
+      <div
+        class="reviewed hideOnEdit"
+        hidden$="[[!_loggedIn]]"
+        aria-hidden="true"
+      ></div>
+      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
+      <div class="show-hide" aria-hidden="true"></div>
     </div>
 
     <template
@@ -331,11 +377,32 @@
           class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
           data-file$="[[_computeFileRange(file)]]"
           tabindex="-1"
+          role="row"
         >
+          <!-- endpoint: change-view-file-list-content-prepend -->
+          <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+            <template
+              is="dom-repeat"
+              items="[[_dynamicPrependedContentEndpoints]]"
+              as="contentEndpoint"
+            >
+              <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+                <gr-endpoint-param name="change" value="[[change]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="changeNum" value="[[changeNum]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="path" value="[[file.__path]]">
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </template>
+          </template>
           <!-- TODO: Remove data-url as it appears its not used -->
           <span
             data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
             class="path"
+            role="gridcell"
           >
             <a
               class="pathLink"
@@ -376,71 +443,126 @@
               </div>
             </template>
           </span>
-          <div class="comments desktop">
-            <span class="drafts">
-              [[_computeDraftsString(changeComments, patchRange, file.__path)]]
-            </span>
-            [[_computeCommentsString(changeComments, patchRange, file.__path)]]
+          <div role="gridcell">
+            <div class="comments desktop">
+              <span class="drafts"
+                ><!-- This comments ensure that span is empty when the function
+                returns empty string.
+              -->[[_computeDraftsString(changeComments, patchRange,
+                file.__path)]]<!-- This comments ensure that span is empty when
+                the function returns empty string.
+           --></span
+              >
+              <span
+                ><!--
+              -->[[_computeCommentsString(changeComments, patchRange,
+                file.__path)]]<!--
+           --></span
+              >
+              <span class="noCommentsScreenReaderText">
+                <!-- Screen readers read the following content only if 2 other
+              spans in the parent div is empty. The content is not visible on
+              the page.
+              Without this span, screen readers don't navigate correctly inside
+              table, because empty div doesn't rendered. For example, VoiceOver
+              jumps back to the whole table.
+              We can use &nbsp instead, but it sounds worse.
+              -->
+                No comments
+              </span>
+            </div>
+            <div class="comments mobile">
+              <span class="drafts"
+                ><!-- This comments ensure that span is empty when the function
+                returns empty string.
+              -->[[_computeDraftsStringMobile(changeComments, patchRange,
+                file.__path)]]<!-- This comments ensure that span is empty when
+                the function returns empty string.
+           --></span
+              >
+              <span
+                ><!--
+             -->[[_computeCommentsStringMobile(changeComments, patchRange,
+                file.__path)]]<!--
+           --></span
+              >
+              <span class="noCommentsScreenReaderText">
+                <!-- The same as for desktop comments -->
+                No comments
+              </span>
+            </div>
           </div>
-          <div class="comments mobile">
-            <span class="drafts">
-              [[_computeDraftsStringMobile(changeComments, patchRange,
-              file.__path)]]
-            </span>
-            [[_computeCommentsStringMobile(changeComments, patchRange,
-            file.__path)]]
-          </div>
-          <div class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]">
-            <svg width="61" height="8">
-              <rect
-                x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
-                y="0"
-                height="8"
-                fill="#388E3C"
-                width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
-              ></rect>
-              <rect
-                x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
-                y="0"
-                height="8"
-                fill="#D32F2F"
-                width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
-              ></rect>
-            </svg>
-          </div>
-          <div class$="[[_computeClass('stats', file.__path)]]">
-            <span
-              class="added"
-              tabindex="0"
-              aria-label$="[[file.lines_inserted]] lines added"
-              hidden$="[[file.binary]]"
+          <div role="gridcell">
+            <!-- The content must be in a separate div. It guarantees, that
+              gridcell always visible for screen readers.
+              For example, without a nested div screen readers pronounce the
+              "Commit message" row content with incorrect column headers.
+            -->
+            <div
+              class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
+              aria-label="A bar that represents the addition and deletion ratio for the current file"
             >
-              +[[file.lines_inserted]]
-            </span>
-            <span
-              class="removed"
-              tabindex="0"
-              aria-label$="[[file.lines_deleted]] lines removed"
-              hidden$="[[file.binary]]"
-            >
-              -[[file.lines_deleted]]
-            </span>
-            <span
-              class$="[[_computeBinaryClass(file.size_delta)]]"
-              hidden$="[[!file.binary]]"
-            >
-              [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
-              file.size_delta)]]
-            </span>
+              <svg width="61" height="8">
+                <rect
+                  x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--positive-green-text-color)"
+                  width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+                <rect
+                  x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--negative-red-text-color)"
+                  width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+              </svg>
+            </div>
           </div>
+          <div class="stats" role="gridcell">
+            <!-- The content must be in a separate div. It guarantees, that
+            gridcell always visible for screen readers.
+            For example, without a nested div screen readers pronounce the
+            "Commit message" row content with incorrect column headers.
+            -->
+            <div class$="[[_computeClass('', file.__path)]]">
+              <span
+                class="added"
+                tabindex="0"
+                aria-label$="[[file.lines_inserted]] lines added"
+                hidden$="[[file.binary]]"
+              >
+                +[[file.lines_inserted]]
+              </span>
+              <span
+                class="removed"
+                tabindex="0"
+                aria-label$="[[file.lines_deleted]] lines removed"
+                hidden$="[[file.binary]]"
+              >
+                -[[file.lines_deleted]]
+              </span>
+              <span
+                class$="[[_computeBinaryClass(file.size_delta)]]"
+                hidden$="[[!file.binary]]"
+              >
+                [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
+                file.size_delta)]]
+              </span>
+            </div>
+          </div>
+          <!-- endpoint: change-view-file-list-content -->
           <template is="dom-if" if="[[_showDynamicColumns]]">
             <template
               is="dom-repeat"
               items="[[_dynamicContentEndpoints]]"
               as="contentEndpoint"
             >
-              <div class$="[[_computeClass('', file.__path)]]">
+              <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
                 <gr-endpoint-decorator name="[[contentEndpoint]]">
+                  <gr-endpoint-param name="change" value="[[change]]">
+                  </gr-endpoint-param>
                   <gr-endpoint-param name="changeNum" value="[[changeNum]]">
                   </gr-endpoint-param>
                   <gr-endpoint-param name="patchRange" value="[[patchRange]]">
@@ -451,25 +573,44 @@
               </div>
             </template>
           </template>
-          <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden="">
+          <div
+            class="reviewed hideOnEdit"
+            role="gridcell"
+            hidden$="[[!_loggedIn]]"
+          >
             <span
               class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
+              aria-hidden$="[[!file.isReviewed]]"
               >Reviewed</span
             >
-            <label>
-              <input
-                class="reviewed"
-                type="checkbox"
-                checked="[[file.isReviewed]]"
-              />
+            <!-- Do not use input type="checkbox" with hidden input and
+                  visible label here. Screen readers don't read/interract
+                  correctly with such input.
+              -->
+            <span
+              class="reviewedSwitch"
+              role="switch"
+              tabindex="0"
+              on-click="_reviewedClick"
+              on-keydown="_reviewedClick"
+              aria-label="Reviewed"
+              aria-checked$="[[_booleanToString(file.isReviewed)]]"
+            >
+              <!-- Trick with tabindex to avoid outline on mouse focus, but
+                preserve focus outline for keyboard navigation -->
               <span
+                tabindex="-1"
                 class="markReviewed"
                 title$="[[_reviewedTitle(file.isReviewed)]]"
                 >[[_computeReviewedText(file.isReviewed)]]</span
               >
-            </label>
+            </span>
           </div>
-          <div class="editFileControls showOnEdit">
+          <div
+            class="editFileControls showOnEdit"
+            role="gridcell"
+            aria-hidden$="[[!editMode]]"
+          >
             <template is="dom-if" if="[[editMode]]">
               <gr-edit-file-controls
                 class$="[[_computeClass('', file.__path)]]"
@@ -477,25 +618,32 @@
               ></gr-edit-file-controls>
             </template>
           </div>
-          <div class="show-hide">
-            <label
+          <div class="show-hide" role="gridcell">
+            <!-- Do not use input type="checkbox" with hidden input and
+                visible label here. Screen readers don't read/interract
+                correctly with such input.
+            -->
+            <span
               class="show-hide"
               data-path$="[[file.__path]]"
               data-expand="true"
+              role="switch"
+              tabindex="0"
+              aria-checked$="[[_isFileExpandedStr(file.__path, _expandedFiles.*)]]"
+              aria-label="Expand file"
+              on-click="_expandedClick"
+              on-keydown="_expandedClick"
             >
-              <input
-                type="checkbox"
-                class="show-hide"
-                checked$="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
-                data-path$="[[file.__path]]"
-                data-expand="true"
-              />
+              <!-- Trick with tabindex to avoid outline on mouse focus, but
+              preserve focus outline for keyboard navigation -->
               <iron-icon
+                class="show-hide-icon"
+                tabindex="-1"
                 id="icon"
                 icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
               >
               </iron-icon>
-            </label>
+            </span>
           </div>
         </div>
         <template
@@ -525,18 +673,19 @@
       <span
         class="added"
         tabindex="0"
-        aria-label$="[[_patchChange.inserted]] lines added"
+        aria-label$="Total [[_patchChange.inserted]] lines added"
       >
         +[[_patchChange.inserted]]
       </span>
       <span
         class="removed"
         tabindex="0"
-        aria-label$="[[_patchChange.deleted]] lines removed"
+        aria-label$="Total [[_patchChange.deleted]] lines removed"
       >
         -[[_patchChange.deleted]]
       </span>
     </div>
+    <!-- endpoint: change-view-file-list-summary -->
     <template is="dom-if" if="[[_showDynamicColumns]]">
       <template
         is="dom-repeat"
@@ -544,6 +693,12 @@
         as="summaryEndpoint"
       >
         <gr-endpoint-decorator name="[[summaryEndpoint]]">
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+          </gr-endpoint-param>
         </gr-endpoint-decorator>
       </template>
     </template>
@@ -554,12 +709,18 @@
   </div>
   <div class="row totalChanges" hidden$="[[_hideBinaryChangeTotals]]">
     <div class="total-stats">
-      <span class="added" aria-label="Total lines added">
+      <span
+        class="added"
+        aria-label$="Total bytes inserted: [[_formatBytes(_patchChange.size_delta_inserted)]] "
+      >
         [[_formatBytes(_patchChange.size_delta_inserted)]]
         [[_formatPercentage(_patchChange.total_size,
         _patchChange.size_delta_inserted)]]
       </span>
-      <span class="removed" aria-label="Total lines removed">
+      <span
+        class="removed"
+        aria-label$="Total bytes removed: [[_formatBytes(_patchChange.size_delta_deleted)]]"
+      >
         [[_formatBytes(_patchChange.size_delta_deleted)]]
         [[_formatPercentage(_patchChange.total_size,
         _patchChange.size_delta_deleted)]]
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index f038f6c..ad0d1cf 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -95,6 +95,7 @@
       });
       stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
+        prefetchDiff() {},
       });
 
       // Element must be wrapped in an element with direct access to the
@@ -836,36 +837,42 @@
         patchNum: '2',
       };
       element.$.fileCursor.setCursorAtIndex(0);
+      const reviewSpy = sandbox.spy(element, '_reviewFile');
+      const toggleExpandSpy = sandbox.spy(element, '_toggleFileExpanded');
 
       flushAsynchronousOperations();
       const fileRows =
           dom(element.root).querySelectorAll('.row:not(.header-row)');
-      const checkSelector = 'input.reviewed[type="checkbox"]';
+      const checkSelector = 'span.reviewedSwitch[role="switch"]';
       const commitMsg = fileRows[0].querySelector(checkSelector);
       const fileAdded = fileRows[1].querySelector(checkSelector);
       const myFile = fileRows[2].querySelector(checkSelector);
 
-      assert.isTrue(commitMsg.checked);
-      assert.isFalse(fileAdded.checked);
-      assert.isTrue(myFile.checked);
+      assert.equal(commitMsg.getAttribute('aria-checked'), 'true');
+      assert.equal(fileAdded.getAttribute('aria-checked'), 'false');
+      assert.equal(myFile.getAttribute('aria-checked'), 'true');
 
       const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
-      const markReviewLabel = commitMsg.nextElementSibling;
+      const markReviewLabel = fileRows[0].querySelector('.markReviewed');
       assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
       assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
 
-      const clickSpy = sandbox.spy(element, '_handleFileListClick');
+      const clickSpy = sandbox.spy(element, '_reviewedClick');
       MockInteractions.tap(markReviewLabel);
-      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
-      assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
+      // assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
+      // assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
       assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
       assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledOnce);
 
       MockInteractions.tap(markReviewLabel);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
       assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
       assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
       assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledTwice);
+
+      assert.isFalse(toggleExpandSpy.called);
     });
 
     test('_computeFileStatusLabel', () => {
@@ -906,13 +913,6 @@
       assert.isTrue(clickSpy.calledTwice);
       assert.isTrue(toggleExpandSpy.calledOnce);
       assert.isFalse(reviewStub.called);
-
-      // Click the reviewed checkbox, resulting in a call to _reviewFile, but
-      // no additional call to _toggleFileExpanded.
-      row.querySelector('.markReviewed').click();
-      assert.isTrue(clickSpy.calledThrice);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isTrue(reviewStub.calledOnce);
     });
 
     test('_handleFileListClick editMode', () => {
@@ -975,12 +975,12 @@
           dom(element.root).querySelectorAll('.row:not(.header-row)');
       // Because the label surrounds the input, the tap event is triggered
       // there first.
-      const showHideLabel = fileRows[0].querySelector('label.show-hide');
       const showHideCheck = fileRows[0].querySelector(
-          'input.show-hide[type="checkbox"]');
-      assert.isNotOk(showHideCheck.checked);
+          'span.show-hide[role="switch"]');
+      const showHideLabel = showHideCheck.querySelector('.show-hide-icon');
+      assert.equal(showHideCheck.getAttribute('aria-checked'), 'false');
       MockInteractions.tap(showHideLabel);
-      assert.isOk(showHideCheck.checked);
+      assert.equal(showHideCheck.getAttribute('aria-checked'), 'true');
       assert.notEqual(
           element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
           -1);
@@ -1001,7 +1001,7 @@
 
       // Tap on a file to generate the diff.
       const row = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) label.show-hide')[0];
+          .querySelectorAll('.row:not(.header-row) span.show-hide')[0];
 
       MockInteractions.tap(row);
       flushAsynchronousOperations();
@@ -1079,20 +1079,22 @@
       const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
       const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
           'handleDiffUpdate');
+      const reInitStub = sandbox.stub(element.$.diffCursor,
+          'reInitAndUpdateStops');
 
       const path = 'path/to/my/file.txt';
       element._filesByPath = {[path]: {}};
       element.expandAllDiffs();
       flushAsynchronousOperations();
       assert.isTrue(element._showInlineDiffs);
-      assert.isTrue(cursorUpdateStub.calledOnce);
+      assert.isTrue(reInitStub.calledOnce);
       assert.equal(collapseStub.lastCall.args[0].length, 0);
 
       element.collapseAllDiffs();
       flushAsynchronousOperations();
       assert.equal(element._expandedFiles.length, 0);
       assert.isFalse(element._showInlineDiffs);
-      assert.isTrue(cursorUpdateStub.calledTwice);
+      assert.isTrue(cursorUpdateStub.calledOnce);
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
@@ -1105,6 +1107,7 @@
         reload() {
           done();
         },
+        prefetchDiff() {},
         cancel() {},
         getCursorStops() { return []; },
         addEventListener(eventName, callback) {
@@ -1162,6 +1165,7 @@
       const diffs = [{
         path: 'p0',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(callCount++, 2);
           return Promise.resolve();
@@ -1169,6 +1173,7 @@
       }, {
         path: 'p1',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(callCount++, 1);
           return Promise.resolve();
@@ -1176,6 +1181,7 @@
       }, {
         path: 'p2',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(callCount++, 0);
           return Promise.resolve();
@@ -1198,6 +1204,7 @@
       const diffs = [{
         path: 'p0',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(reviewStub.callCount, 2);
           assert.equal(callCount++, 2);
@@ -1206,6 +1213,7 @@
       }, {
         path: 'p1',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(reviewStub.callCount, 1);
           assert.equal(callCount++, 1);
@@ -1214,6 +1222,7 @@
       }, {
         path: 'p2',
         style: {},
+        prefetchDiff() {},
         reload() {
           assert.equal(reviewStub.callCount, 0);
           assert.equal(callCount++, 0);
@@ -1236,6 +1245,7 @@
       const diffs = [{
         path: 'p',
         style: {},
+        prefetchDiff() {},
         reload() { return Promise.resolve(); },
       }];
 
@@ -1565,6 +1575,7 @@
       });
       stub('gr-diff-host', {
         reload() { return Promise.resolve(); },
+        prefetchDiff() {},
       });
 
       // Element must be wrapped in an element with direct access to the
@@ -1707,7 +1718,6 @@
       test('n key with some files expanded and no shift key', () => {
         MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
         flushAsynchronousOperations();
-        assert.equal(nextChunkStub.callCount, 1);
 
         // Handle N key should return before calling diff cursor functions.
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
@@ -1715,21 +1725,21 @@
         assert.isFalse(nextCommentStub.called);
 
         // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 2);
+        assert.equal(nextChunkStub.callCount, 1);
         assert.equal(element.filesExpanded, 'some');
       });
 
       test('n key with some files expanded and shift key', () => {
         MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
         flushAsynchronousOperations();
-        assert.equal(nextChunkStub.callCount, 1);
+        assert.equal(nextChunkStub.callCount, 0);
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
         assert.isTrue(nKeySpy.called);
         assert.isTrue(nextCommentStub.called);
 
         // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
+        assert.equal(nextChunkStub.callCount, 0);
         assert.equal(element.filesExpanded, 'some');
       });
 
@@ -1742,7 +1752,7 @@
         assert.isFalse(nextCommentStub.called);
 
         // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 2);
+        assert.equal(nextChunkStub.callCount, 1);
         assert.isTrue(element._showInlineDiffs);
       });
 
@@ -1755,7 +1765,7 @@
         assert.isTrue(nextCommentStub.called);
 
         // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
+        assert.equal(nextChunkStub.callCount, 0);
         assert.isTrue(element._showInlineDiffs);
       });
     });
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
index 132cd16..c42c734 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -74,7 +74,9 @@
   }
 
   _computeGroups(includedIn, filterText) {
-    if (!includedIn) { return []; }
+    if (!includedIn || filterText === undefined) {
+      return [];
+    }
 
     const filter = item => !filterText.length ||
         item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
@@ -105,12 +107,6 @@
   _computeLoadingClass(loaded) {
     return loaded ? 'loading loaded' : 'loading';
   }
-
-  _onFilterChanged() {
-    this.debounce('filter-change', () => {
-      this._filterText = this.$.filterInput.bindValue;
-    }, 100);
-  }
 }
 
 customElements.define(GrIncludedInDialog.is, GrIncludedInDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
index f5948e8..4670e6a 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
@@ -67,18 +67,21 @@
     }
   </style>
   <header>
-    <h1 id="title">Included In:</h1>
+    <h1 id="title" class="heading-1">Included In:</h1>
     <span class="closeButtonContainer">
       <gr-button id="closeButton" link="" on-click="_handleCloseTap"
         >Close</gr-button
       >
     </span>
-    <iron-input placeholder="Filter" on-bind-value-changed="_onFilterChanged">
+    <iron-input
+      id="filterInput"
+      placeholder="Filter"
+      bind-value="{{_filterText}}"
+    >
       <input
-        id="filterInput"
         is="iron-input"
         placeholder="Filter"
-        on-bind-value-changed="_onFilterChanged"
+        bind-value="{{_filterText}}"
       />
     </iron-input>
   </header>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
index fe6119e..5d5b1fc 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
@@ -81,5 +81,20 @@
       {title: 'Tags', items: ['v2.0', 'v2.1']},
     ]);
   });
+
+  test('_computeGroups with .bindValue', done => {
+    element.$.filterInput.bindValue = 'stable-3.2';
+    const includedIn = {branches: [], tags: []};
+    includedIn.branches.push('master', 'stable-3.2');
+
+    setTimeout(() => {
+      const filterText = element._filterText;
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Branches', items: ['stable-3.2']},
+      ]);
+
+      done();
+    });
+  });
 });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 5b9730b..f5742ea 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -148,6 +148,13 @@
     // Needed because when the selected item changes, it first changes to
     // nothing and then to the new item.
     if (!e.target.selectedItem) { return; }
+    for (const item of this.$.labelSelector.items) {
+      if (e.target.selectedItem === item) {
+        item.setAttribute('aria-checked', 'true');
+      } else {
+        item.removeAttribute('aria-checked');
+      }
+    }
     this._selectedValueText = e.target.selectedItem.getAttribute('title');
     // Needed to update the style of the selected button.
     this.updateStyles();
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
index 77148ad..fb0f9e8 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
@@ -53,7 +53,6 @@
           --button-background-color,
           var(--table-header-background-color)
         );
-        color: var(--primary-text-color);
         padding: 0 var(--spacing-m);
         @apply --vote-chip-styles;
       }
@@ -94,7 +93,9 @@
       }
     }
   </style>
-  <span class="labelNameCell">[[label.name]]</span>
+  <span class="labelNameCell" id="labelName" aria-hidden="true"
+    >[[label.name]]</span
+  >
   <div class="buttonsCell">
     <template
       is="dom-repeat"
@@ -109,9 +110,12 @@
       selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
       hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
       on-selected-item-changed="_setSelectedValueText"
+      role="radiogroup"
+      aria-labelledby="labelName"
     >
       <template is="dom-repeat" items="[[_items]]" as="value">
         <gr-button
+          role="radio"
           vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
           has-tooltip=""
           data-name$="[[label.name]]"
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
index 0fc6e4c..ca4fac3 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -104,6 +104,20 @@
     sandbox.restore();
   });
 
+  function checkAriaCheckedValid() {
+    const items = element.$.labelSelector.items;
+    const selectedItem = element.selectedItem;
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i];
+      if (items[i] === selectedItem) {
+        assert.isTrue(item.hasAttribute('aria-checked'), `item ${i}`);
+        assert.equal(item.getAttribute('aria-checked'), 'true', `item ${i}`);
+      } else {
+        assert.isFalse(item.hasAttribute('aria-checked'), `item ${i}`);
+      }
+    }
+  }
+
   test('label picker', () => {
     const labelsChangedHandler = sandbox.stub();
     element.addEventListener('labels-changed', labelsChangedHandler);
@@ -120,6 +134,7 @@
     const detail = labelsChangedHandler.args[0][0].detail;
     assert.equal(detail.name, 'Verified');
     assert.equal(detail.value, '-1');
+    checkAriaCheckedValid();
   });
 
   test('_computeVoteAttribute', () => {
@@ -163,6 +178,7 @@
             .textContent.trim(), '+1');
     assert.strictEqual(
         element.$.selectedValueLabel.textContent.trim(), 'good');
+    checkAriaCheckedValid();
   });
 
   test('do not display tooltips on touch devices', () => {
@@ -243,6 +259,7 @@
     assert.strictEqual(selector.selected, ' 0');
     assert.strictEqual(
         element.$.selectedValueLabel.textContent.trim(), 'No score');
+    checkAriaCheckedValid();
   });
 
   test('without permitted labels', () => {
@@ -339,6 +356,7 @@
     };
     flushAsynchronousOperations();
     assert.strictEqual(element.selectedValue, '-1');
+    checkAriaCheckedValid();
   });
 
   test('default_value is null if not permitted', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index b8a405a..e065008 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -74,14 +74,6 @@
         computed: '_computeAuthor(message)',
       },
       /**
-       * Used in cleaner_changelog experiment
-       *
-       * @type {Array} - array of threads attached to the message
-       */
-      commentThreads: {
-        type: Array,
-      },
-      /**
        * TODO(taoalpha): remove once the change log experiment is launched
        *
        * @type {Object} - a map on file and comments on it
@@ -140,12 +132,12 @@
         type: String,
         computed:
             '_computeMessageContentCollapsed(message.message, message.tag,' +
-            ' commentThreads)',
+            ' message.commentThreads)',
       },
       _commentCountText: {
         type: Number,
-        computed: '_computeCommentCountText(comments, commentThreads,'
-            + ' _isCleanerLogExperimentEnabled)',
+        computed: '_computeCommentCountText(comments,'
+            + ' message.commentThreads.length, _isCleanerLogExperimentEnabled)',
       },
       _loggedIn: {
         type: Boolean,
@@ -205,17 +197,16 @@
     }
   }
 
-  _computeCommentCountText(comments, threads, isCleanerLogExperimentEnabled) {
+  _computeCommentCountText(
+      comments, threadsLength, isCleanerLogExperimentEnabled) {
     // TODO(taoalpha): clean up after cleaner-changelog experiment launched
     if (isCleanerLogExperimentEnabled) {
-      if (!threads) return undefined;
-      const count = threads.length;
-      if (count === 0) {
+      if (threadsLength === 0) {
         return undefined;
-      } else if (count === 1) {
+      } else if (threadsLength === 1) {
         return '1 comment';
       } else {
-        return `${count} comments`;
+        return `${threadsLength} comments`;
       }
     } else {
       if (!comments) return undefined;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
index 84713db..de4a72a 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
@@ -132,7 +132,7 @@
     }
     .score {
       border-radius: var(--border-radius);
-      color: var(--primary-text-color);
+      color: var(--vote-text-color);
       display: inline-block;
       padding: 0 var(--spacing-s);
       text-align: center;
@@ -258,8 +258,8 @@
             <template is="dom-if" if="[[_isCleanerLogExperimentEnabled]]">
               <gr-thread-list
                 change="[[change]]"
-                hidden$="[[!commentThreads.length]]"
-                threads="[[commentThreads]]"
+                hidden$="[[!message.commentThreads.length]]"
+                threads="[[message.commentThreads]]"
                 change-num="[[changeNum]]"
                 logged-in="[[_loggedIn]]"
                 hide-toggle-buttons
@@ -280,7 +280,7 @@
                 items="[[update.reviewers]]"
                 as="reviewer"
               >
-                <gr-account-chip account="[[reviewer]]"> </gr-account-chip>
+                <gr-account-link account="[[reviewer]]"> </gr-account-link>
               </template>
             </div>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
index ccc4a76..ae3a7d2 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
@@ -275,8 +275,9 @@
    * all messages and updates, aligns or massages some of the properties.
    */
   _computeCombinedMessages(messages, reviewerUpdates, changeComments) {
-    messages = messages || [];
-    reviewerUpdates = reviewerUpdates || [];
+    const params = [messages, reviewerUpdates, changeComments];
+    if (params.some(o => o === undefined)) return [];
+
     let mi = 0;
     let ri = 0;
     let combinedMessages = [];
@@ -312,6 +313,11 @@
       m.commentThreads = computeThreads(m, combinedMessages, changeComments);
       m._revision_number = computeRevision(m, combinedMessages);
       m.tag = computeTag(m);
+    });
+    // computeIsImportant() depends on tags and revision numbers already being
+    // updated for all messages, so we have to compute this in its own forEach
+    // loop.
+    combinedMessages.forEach(m => {
       m.isImportant = computeIsImportant(m, combinedMessages);
     });
     return combinedMessages;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
index 83edab3..56cb960 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
@@ -72,8 +72,10 @@
         <paper-toggle-button
           class="showAllActivityToggle"
           checked="{{_showAllActivity}}"
+          aria-labelledby="showAllEntriesLabel"
+          role="switch"
         ></paper-toggle-button>
-        <div>
+        <div id="showAllEntriesLabel">
           <span>Show all entries</span>
           <span class="hiddenEntries" hidden$="[[_showAllActivity]]">
             ([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
@@ -109,9 +111,9 @@
     filter="_isMessageVisible"
   >
     <gr-message
+      change="[[change]]"
       change-num="[[changeNum]]"
       message="[[message]]"
-      comment-threads="[[message.commentThreads]]"
       project-name="[[projectName]]"
       show-reply-button="[[showReplyButtons]]"
       on-message-anchor-tap="_handleAnchorClick"
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
index 71f25b7..c520b05 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
@@ -302,11 +302,11 @@
       const messageElements = getMessages();
       // threads
       assert.equal(
-          messageElements[0].commentThreads.length,
+          messageElements[0].message.commentThreads.length,
           3);
       // first thread contains 1 comment
       assert.equal(
-          messageElements[0].commentThreads[0].comments.length,
+          messageElements[0].message.commentThreads[0].comments.length,
           1);
     });
 
@@ -420,6 +420,17 @@
       assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
     });
 
+    test('isImportant is evaluated after tag update', () => {
+      const m1 = randomMessage(
+          {tag: MessageTag.TAG_NEW_PATCHSET, _revision_number: 1});
+      const m2 = randomMessage(
+          {tag: MessageTag.TAG_NEW_WIP_PATCHSET, _revision_number: 2});
+      element.messages = [m1, m2];
+      flushAsynchronousOperations();
+      assert.isFalse(m1.isImportant);
+      assert.isTrue(m2.isImportant);
+    });
+
     test('messages without author do not throw', () => {
       const messages = [{
         _index: 5,
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
index ddae150..5adfc53 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
@@ -79,8 +79,10 @@
       <paper-toggle-button
         id="automatedMessageToggle"
         checked="{{_hideAutomated}}"
+        aria-labelledby="onlyCommentsLabel"
+        role="switch"
       ></paper-toggle-button
-      >Only comments
+      ><span id="onlyCommentsLabel">Only comments</span>
       <span class="transparent separator"></span>
     </span>
     <gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
index 687dbd7..2321deb 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
@@ -21,9 +21,6 @@
     :host {
       display: block;
     }
-    h3 {
-      margin: var(--spacing-m) 0 0;
-    }
     section {
       margin-bottom: 1.4em; /* Same as line height for collapse purposes */
     }
@@ -79,7 +76,7 @@
       color: #1b5e20;
     }
     .submittableCheck {
-      color: var(--vote-text-color-recommended);
+      color: var(--positive-green-text-color);
       display: none;
     }
     .submittableCheck.submittable {
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 fe3bc67..e37c84a 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
@@ -295,6 +295,19 @@
     this.addEventListener('comment-editing-changed', e => {
       this._commentEditing = e.detail;
     });
+
+    // Plugins on reply-reviewers endpoint can take advantage of these
+    // events to add / remove reviewers
+
+    this.addEventListener('add-reviewer', e => {
+      // Only support account type, see more from:
+      // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
+      this.$.reviewers.addAccountItem({account: e.detail.reviewer});
+    });
+
+    this.addEventListener('remove-reviewer', e => {
+      this.$.reviewers.removeAccount(e.detail.reviewer);
+    });
   }
 
   /** @override */
@@ -643,11 +656,11 @@
   }
 
   _computeHideDraftList(draftCommentThreads) {
-    return draftCommentThreads.length === 0;
+    return !draftCommentThreads || draftCommentThreads.length === 0;
   }
 
   _computeDraftsTitle(draftCommentThreads) {
-    const total = draftCommentThreads.length;
+    const total = draftCommentThreads ? draftCommentThreads.length : 0;
     if (total == 0) { return ''; }
     if (total == 1) { return '1 Draft'; }
     if (total > 1) { return total + ' Drafts'; }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
index 7286678..79f38a6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
@@ -97,20 +97,14 @@
     }
     .textareaContainer,
     #textarea,
-    gr-endpoint-decorator {
+    gr-endpoint-decorator[name='reply-text'] {
       display: flex;
       width: 100%;
     }
-    gr-endpoint-decorator[name='reply-label-scores'] {
-      display: block;
-    }
     .previewContainer gr-formatted-text {
       background: var(--table-header-background-color);
       padding: var(--spacing-l);
     }
-    .draftsContainer h3 {
-      margin-top: var(--spacing-xs);
-    }
     #checkingStatusLabel,
     #notLatestLabel {
       margin-left: var(--spacing-l);
@@ -143,20 +137,25 @@
   </style>
   <div class="container" tabindex="-1">
     <section class="peopleContainer">
-      <div class="peopleList">
-        <div class="peopleListLabel">Reviewers</div>
-        <gr-account-list
-          id="reviewers"
-          accounts="{{_reviewers}}"
-          removable-values="[[change.removable_reviewers]]"
-          filter="[[filterReviewerSuggestion]]"
-          pending-confirmation="{{_reviewerPendingConfirmation}}"
-          placeholder="Add reviewer..."
-          on-account-text-changed="_handleAccountTextEntry"
-          suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
-        >
-        </gr-account-list>
-      </div>
+      <gr-endpoint-decorator name="reply-reviewers">
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <gr-account-list
+            id="reviewers"
+            accounts="{{_reviewers}}"
+            removable-values="[[change.removable_reviewers]]"
+            filter="[[filterReviewerSuggestion]]"
+            pending-confirmation="{{_reviewerPendingConfirmation}}"
+            placeholder="Add reviewer..."
+            on-account-text-changed="_handleAccountTextEntry"
+            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
+          >
+          </gr-account-list>
+          <gr-endpoint-slot name="right"></gr-endpoint-slot>
+        </div>
+        <gr-endpoint-slot name="below"></gr-endpoint-slot>
+      </gr-endpoint-decorator>
       <div class="peopleList">
         <div class="peopleListLabel">CC</div>
         <gr-account-list
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index f2455a2..bd91990 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -25,6 +25,7 @@
 import {parseDate} from '../../../utils/date-util.js';
 
 import {NO_THREADS_MSG} from '../../../constants/messages.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
 
 /**
  * Fired when a comment is saved or deleted
@@ -74,47 +75,99 @@
     };
   }
 
-  static get observers() { return ['_computeSortedThreads(threads.*)']; }
+  static get observers() {
+    return ['_updateSortedThreads(threads, threads.splices)'];
+  }
 
   _computeShowDraftToggle(loggedIn) {
     return loggedIn ? 'show' : '';
   }
 
+  _compareThreads(c1, c2) {
+    if (c1.thread.path !== c2.thread.path) {
+      // '/PATCHSET' will not come before '/COMMIT' when sorting
+      // alphabetically so move it to the front explicitly
+      if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return -1;
+      }
+      if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return 1;
+      }
+      return c1.thread.path.localeCompare(c2.thread.path);
+    }
+
+    // Patchset comments have no line/range associated with them
+    if (c1.thread.line !== c2.thread.line) {
+      if (!c1.thread.line || !c2.thread.line) {
+        // one of them is a file level comment, show first
+        return c1.thread.line ? 1 : -1;
+      }
+      return c1.thread.line < c2.thread.line ? -1 : 1;
+    }
+
+    if (c1.thread.patchNum !== c2.thread.patchNum) {
+      return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
+    }
+
+    if (c2.unresolved !== c1.unresolved) {
+      if (!c1.unresolved) { return 1; }
+      if (!c2.unresolved) { return -1; }
+    }
+
+    if (c2.hasDraft !== c1.hasDraft) {
+      if (!c1.hasDraft) { return 1; }
+      if (!c2.hasDraft) { return -1; }
+    }
+
+    const c1Date = c1.__date || parseDate(c1.updated);
+    const c2Date = c2.__date || parseDate(c2.updated);
+    const dateCompare = c2Date - c1Date;
+    if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
+      return 0;
+    }
+    return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+  }
+
   /**
+   * Observer on threads and update _sortedThreads when needed.
    * Order as follows:
-   *  - Unresolved threads with drafts (reverse chronological)
-   *  - Unresolved threads without drafts (reverse chronological)
-   *  - Resolved threads with drafts (reverse chronological)
-   *  - Resolved threads without drafts (reverse chronological)
+   *  - Patchset level threads (descending based on patchset number)
+   *    - unresolved
+          - comments with drafts
+          - comments without drafts
+   *    - resolved
+          - comments with drafts
+          - comments without drafts
+   *  - File name
+   *    - Line number
+   *      - Unresolved (descending based on patchset number)
+   *        - comments with drafts
+   *        - comments without drafts
+   *      - Resolved (descending based on patchset number)
+   *        - comments with drafts
+   *        - comments without drafts
    *
-   * @param {!Object} changeRecord
+   * @param {Array<Object>} threads
+   * @param {!Object} spliceRecord
    */
-  _computeSortedThreads(changeRecord) {
-    const baseThreads = changeRecord.base;
-    const threads = changeRecord.value;
-    if (!baseThreads) { return []; }
-    // TODO: should change how data flows to solve the root cause
+  _updateSortedThreads(threads, spliceRecord) {
+    if (!threads) {
+      this._sortedThreads = [];
+      return;
+    }
     // We only want to sort on thread additions / removals to avoid
     // re-rendering on modifications (add new reply / edit draft etc)
     //  https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
-    let shouldSort = true;
-    if (threads.indexSplices) {
-      // Array splice mutations
-      shouldSort = threads.indexSplices.addedCount !== 0
-        || threads.indexSplices.removed.length;
-    } else {
-      // A replace mutation
-      shouldSort = threads.length !== baseThreads.length;
-    }
-    this._updateSortedThreads(baseThreads, shouldSort);
-  }
+    const isArrayMutation = spliceRecord &&
+      (spliceRecord.indexSplices.addedCount !== 0
+        || spliceRecord.indexSplices.removed.length);
 
-  _updateSortedThreads(threads, shouldSort) {
     if (this._sortedThreads
         && this._sortedThreads.length === threads.length
-        && !shouldSort) {
+        && !isArrayMutation) {
       // Instead of replacing the _sortedThreads which will trigger a re-render,
       // we override all threads inside of it
+
       for (const thread of threads) {
         const idxInSortedThreads = this._sortedThreads
             .findIndex(t => t.rootId === thread.rootId);
@@ -125,52 +178,38 @@
 
     const threadsWithInfo = threads
         .map(thread => this._getThreadWithStatusInfo(thread));
-    this._sortedThreads = threadsWithInfo.sort((c1, c2) => {
-      // threads will be sorted by:
-      // - unresolved first
-      // - with drafts
-      // - file path
-      // - line
-      // - updated time
-      if (c2.unresolved || c1.unresolved) {
-        if (!c1.unresolved) { return 1; }
-        if (!c2.unresolved) { return -1; }
-      }
-
-      if (c2.hasDraft || c1.hasDraft) {
-        if (!c1.hasDraft) { return 1; }
-        if (!c2.hasDraft) { return -1; }
-      }
-
-      // TODO: Update here once we introduce patchset level comments
-      // they may not have or have a special line or path attribute
-
-      if (c1.thread.path !== c2.thread.path) {
-        return c1.thread.path.localeCompare(c2.thread.path);
-      }
-
-      // File level comments (no `line` property)
-      // should always show before any lines
-      if ([c1, c2].some(c => c.thread.line === undefined)) {
-        if (!c1.thread.line) { return -1; }
-        if (!c2.thread.line) { return 1; }
-      } else if (c1.thread.line !== c2.thread.line) {
-        return c1.thread.line - c2.thread.line;
-      }
-
-      const c1Date = c1.__date || parseDate(c1.updated);
-      const c2Date = c2.__date || parseDate(c2.updated);
-      const dateCompare = c2Date - c1Date;
-      if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
-        return 0;
-      }
-      return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-    }).map(threadInfo => threadInfo.thread);
+    this._sortedThreads = threadsWithInfo.sort((t1, t2) =>
+      this._compareThreads(t1, t2)).map(threadInfo => threadInfo.thread);
   }
 
-  _shouldShowThread(
-      thread, unresolvedOnly, draftsOnly, onlyShowRobotCommentsWithHumanReply
-  ) {
+  _isFirstThreadWithFileName(sortedThreads, thread, unresolvedOnly, draftsOnly,
+      onlyShowRobotCommentsWithHumanReply) {
+    const threads = sortedThreads.filter(t => this._shouldShowThread(
+        t, unresolvedOnly, draftsOnly,
+        onlyShowRobotCommentsWithHumanReply));
+    const index = threads.findIndex(t => t.rootId === thread.rootId);
+    if (index === -1) {
+      return false;
+    }
+    return index === 0 || (threads[index - 1].path !== threads[index].path);
+  }
+
+  _shouldRenderSeparator(sortedThreads, thread, unresolvedOnly, draftsOnly,
+      onlyShowRobotCommentsWithHumanReply) {
+    const threads = sortedThreads.filter(t => this._shouldShowThread(
+        t, unresolvedOnly, draftsOnly,
+        onlyShowRobotCommentsWithHumanReply));
+    const index = threads.findIndex(t => t.rootId === thread.rootId);
+    if (index === -1) {
+      return false;
+    }
+    return index > 0 && this._isFirstThreadWithFileName(sortedThreads,
+        thread, unresolvedOnly, draftsOnly,
+        onlyShowRobotCommentsWithHumanReply);
+  }
+
+  _shouldShowThread(thread, unresolvedOnly, draftsOnly,
+      onlyShowRobotCommentsWithHumanReply) {
     if ([
       thread,
       unresolvedOnly,
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
index 162a7c0..bb87db8 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
@@ -53,6 +53,10 @@
     .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
       display: block;
     }
+    .thread-separator {
+      border-top: 1px solid var(--border-color);
+      margin-top: var(--spacing-xl);
+    }
   </style>
   <template is="dom-if" if="[[!hideToggleButtons]]">
     <div class="header">
@@ -85,11 +89,18 @@
         is="dom-if"
         if="[[_shouldShowThread(thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
       >
+        <template
+          is="dom-if"
+          if="[[_shouldRenderSeparator(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        >
+          <div class="thread-separator"></div>
+        </template>
         <gr-comment-thread
           show-file-path=""
           change-num="[[changeNum]]"
           comments="[[thread.comments]]"
           comment-side="[[thread.commentSide]]"
+          show-file-name="[[_isFirstThreadWithFileName(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
           project-name="[[change.project]]"
           is-on-parent="[[_isOnParent(thread.commentSide)]]"
           line-num="[[thread.line]]"
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
index 2195581..3b29ea7 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -36,6 +36,8 @@
 import './gr-thread-list.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {NO_THREADS_MSG} from '../../../constants/messages.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+
 suite('gr-thread-list tests', () => {
   let element;
   let sandbox;
@@ -123,7 +125,7 @@
             id: '8caddf38_44770ec1',
             updated: '2018-02-13 22:48:40.000000000',
             message: 'Another unresolved comment',
-            unresolved: true,
+            unresolved: false,
           },
         ],
         patchNum: 2,
@@ -178,6 +180,40 @@
       {
         comments: [
           {
+            id: 'patchset_level_1',
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'patchset comment 1',
+            unresolved: false,
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 2,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_1',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_2',
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'patchset comment 2',
+            unresolved: false,
+            __editing: false,
+            patch_set: '3',
+          },
+        ],
+        patchNum: 3,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_2',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
             __path: '/COMMIT_MSG',
             author: {
               _account_id: 1000000,
@@ -267,6 +303,16 @@
     assert.equal(getVisibleThreads().length, element.threads.length);
   });
 
+  test('showing file name takes visible threads into account', () => {
+    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
+        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element.onlyShowRobotCommentsWithHumanReply), true);
+    element._unresolvedOnly = true;
+    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
+        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element.onlyShowRobotCommentsWithHumanReply), false);
+  });
+
   test('onlyShowRobotCommentsWithHumanReply ', () => {
     element.onlyShowRobotCommentsWithHumanReply = true;
     flushAsynchronousOperations();
@@ -276,29 +322,153 @@
     assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
   });
 
+  suite('_compareThreads', () => {
+    test('patchset comes before any other file', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
+      const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
+
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      // assigning values to properties such that t2 should come first
+      t1.patchNum = 1;
+      t2.patchNum = 2;
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('file path is compared lexicographically', () => {
+      const t1 = {thread: {path: 'a.txt'}};
+      const t2 = {thread: {path: 'b.txt'}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      t1.patchNum = 1;
+      t2.patchNum = 2;
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('patchset comments sorted by reverse patchset', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}};
+      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 2}};
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('patchset comments with same patchset picks unresolved first', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}, unresolved: true};
+      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}, unresolved: false};
+      t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('file level comment before line', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2}};
+      const t2 = {thread: {path: 'a.txt'}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      // give preference to t1 in unresolved/draft properties
+      t1.unresolved = t1.hasDraft = true;
+      t2.unresolved = t2.unresolved = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('comments sorted by line', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2}};
+      const t2 = {thread: {path: 'a.txt', line: 3}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('comments on same line sorted by reverse patchset', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
+      const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      // give preference to t1 in unresolved/draft properties
+      t1.unresolved = t1.hasDraft = true;
+      t2.unresolved = t2.unresolved = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('comments on same line & patchset sorted by unresolved first',
+        () => {
+          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true};
+          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: false};
+          t1.patchNum = t2.patchNum = 1;
+          assert.equal(element._compareThreads(t1, t2), -1);
+          assert.equal(element._compareThreads(t2, t1), 1);
+
+          t2.hasDraft = true;
+          t1.hasDraft = false;
+          assert.equal(element._compareThreads(t1, t2), -1);
+          assert.equal(element._compareThreads(t2, t1), 1);
+        });
+
+    test('comments on same line & patchset & unresolved sorted by draft',
+        () => {
+          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true, hasDraft: false};
+          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true, hasDraft: true};
+          t1.patchNum = t2.patchNum = 1;
+          assert.equal(element._compareThreads(t1, t2), 1);
+          assert.equal(element._compareThreads(t2, t1), -1);
+        });
+  });
+
   test('_computeSortedThreads', () => {
-    assert.equal(element._sortedThreads.length, 7);
-    // Draft and unresolved for commit-msg at line 5
-    assert.equal(element._sortedThreads[0].rootId,
-        'ecf0b9fa_fe1a5f62');
-    // unresolved no draft and file level
-    assert.equal(element._sortedThreads[1].rootId,
-        '8caddf38_44770ec1');
-    // unresolved no draft at line 4
-    assert.equal(element._sortedThreads[2].rootId,
-        'scaddf38_44770ec1');
-    // unresolved no draft at line 5
-    assert.equal(element._sortedThreads[3].rootId,
-        'rc1');
-    // Unresolved no draft at line 7
-    assert.equal(element._sortedThreads[4].rootId,
-        'rc2');
-    // resolved and draft on COMMIT_MSG
-    assert.equal(element._sortedThreads[5].rootId,
-        'zcf0b9fa_fe1a5f62');
-    // resolved and on file test.txt
-    assert.equal(element._sortedThreads[6].rootId,
-        '09a9fb0a_1484e6cf');
+    assert.equal(element._sortedThreads.length, 9);
+    const expectedSortedRootIds = [
+      'patchset_level_2', // Posted on Patchset 3
+      'patchset_level_1', // Posted on Patchset 2
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
   });
 
   test('thread removal and sort again', () => {
@@ -308,24 +478,20 @@
           composed: true, bubbles: true,
         }));
     flushAsynchronousOperations();
-    assert.equal(element._sortedThreads.length, 6);
-    assert.equal(element._sortedThreads[0].rootId,
-        'ecf0b9fa_fe1a5f62');
-    // unresolved no draft and file level
-    assert.equal(element._sortedThreads[1].rootId,
-        '8caddf38_44770ec1');
-    // unresolved no draft at line 4
-    assert.equal(element._sortedThreads[2].rootId,
-        'scaddf38_44770ec1');
-    // unresolved no draft at line 5
-    assert.equal(element._sortedThreads[3].rootId,
-        'rc1');
-    // resolved and draft
-    assert.equal(element._sortedThreads[4].rootId,
-        'zcf0b9fa_fe1a5f62');
-    // resolved and on file test.txt
-    assert.equal(element._sortedThreads[5].rootId,
-        '09a9fb0a_1484e6cf');
+    assert.equal(element._sortedThreads.length, 8);
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
   });
 
   test('modification on thread shold not trigger sort again', () => {
@@ -343,32 +509,29 @@
     assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
 
     // exact same order as in _computeSortedThreads
-    assert.equal(element._sortedThreads.length, 7);
-    // Draft and unresolved for commit-msg at line 5
-    assert.equal(element._sortedThreads[0].rootId,
-        'ecf0b9fa_fe1a5f62');
-    // unresolved no draft and file level
-    assert.equal(element._sortedThreads[1].rootId,
-        '8caddf38_44770ec1');
-    // unresolved no draft at line 4
-    assert.equal(element._sortedThreads[2].rootId,
-        'scaddf38_44770ec1');
-    // unresolved no draft at line 5
-    assert.equal(element._sortedThreads[3].rootId,
-        'rc1');
-    // Unresolved no draft at line 7
-    assert.equal(element._sortedThreads[4].rootId,
-        'rc2');
-    // resolved and draft on COMMIT_MSG
-    assert.equal(element._sortedThreads[5].rootId,
-        'zcf0b9fa_fe1a5f62');
-    // resolved and on file test.txt
-    assert.equal(element._sortedThreads[6].rootId,
-        '09a9fb0a_1484e6cf');
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('reset sortedThreads when threads set to undefiend', () => {
+    element.threads = undefined;
+    assert.deepEqual(element._sortedThreads, []);
   });
 
   test('non-equal length of sortThreads and threads' +
-    ' shold trigger sort again', () => {
+    ' should trigger sort again', () => {
     const modifiedThreads = [...element.threads];
     const currentSortedThreads = [...element._sortedThreads];
     element._sortedThreads = [];
@@ -376,36 +539,27 @@
     assert.deepEqual(currentSortedThreads, element._sortedThreads);
 
     // exact same order as in _computeSortedThreads
-    assert.equal(element._sortedThreads.length, 7);
-    // Draft and unresolved for commit-msg at line 5
-    assert.equal(element._sortedThreads[0].rootId,
-        'ecf0b9fa_fe1a5f62');
-    // /COMMIT_MSG
-    // unresolved no draft and file level
-    assert.equal(element._sortedThreads[1].rootId,
-        '8caddf38_44770ec1');
-    // unresolved no draft at line 4
-    assert.equal(element._sortedThreads[2].rootId,
-        'scaddf38_44770ec1');
-    // unresolved no draft at line 5
-    assert.equal(element._sortedThreads[3].rootId,
-        'rc1');
-    // Unresolved no draft at line 7
-    assert.equal(element._sortedThreads[4].rootId,
-        'rc2');
-    // resolved and draft on COMMIT_MSG
-    assert.equal(element._sortedThreads[5].rootId,
-        'zcf0b9fa_fe1a5f62');
-    // resolved and on file test.txt
-    assert.equal(element._sortedThreads[6].rootId,
-        '09a9fb0a_1484e6cf');
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
   });
 
   test('toggle unresolved only shows unresolved comments', () => {
     MockInteractions.tap(element.shadowRoot.querySelector(
         '#unresolvedToggle'));
     flushAsynchronousOperations();
-    assert.equal(getVisibleThreads().length, 5);
+    assert.equal(getVisibleThreads().length, 4);
   });
 
   test('toggle drafts only shows threads with draft comments', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 903552a..23a4c55 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -90,7 +90,9 @@
       return undefined;
     }
 
-    const links = [{name: 'Settings', url: '/settings/'}];
+    const links = [];
+    links.push({name: 'Settings', url: '/settings/'});
+    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
     if (switchAccountUrl) {
       const replacements = {path};
       const url = this._interpolateUrl(switchAccountUrl, replacements);
@@ -107,6 +109,11 @@
     ];
   }
 
+  _handleShortcutsTap(e) {
+    this.dispatchEvent(new CustomEvent('show-keyboard-shortcuts',
+        {bubbles: true, composed: true}));
+  }
+
   _handleLocationChange() {
     this._path =
         window.location.pathname +
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
index b47894e..5db7923 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
@@ -37,6 +37,7 @@
     link=""
     items="[[links]]"
     top-content="[[topContent]]"
+    on-tap-item-shortcuts="_handleShortcutsTap"
     horizontal-align="right"
   >
     <span hidden$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 6c8ed68..a366d09 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -84,12 +84,12 @@
     assert.isUndefined(element._getLinks(null));
 
     // No switch account link.
-    assert.equal(element._getLinks(null, '').length, 2);
+    assert.equal(element._getLinks(null, '').length, 3);
 
     // Unparameterized switch account link.
     let links = element._getLinks('/switch-account', '');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
       name: 'Switch account',
       url: '/switch-account',
       external: true,
@@ -97,8 +97,8 @@
 
     // Parameterized switch account link.
     links = element._getLinks('/switch-account${path}', '/c/123');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
       name: 'Switch account',
       url: '/switch-account/c/123',
       external: true,
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
index 78b576e..e850fbe 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
@@ -55,7 +55,7 @@
     }
   </style>
   <header>
-    <h3>Keyboard shortcuts</h3>
+    <h3 class="heading-3">Keyboard shortcuts</h3>
     <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
   </header>
   <main>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
index 19e833c..e50b408 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
@@ -199,6 +199,7 @@
       ></gr-endpoint-decorator>
       <gr-smart-search
         id="search"
+        label="Search for changes"
         search-query="{{searchQuery}}"
       ></gr-smart-search>
       <gr-endpoint-decorator
@@ -221,6 +222,8 @@
           class="settingsButton"
           href$="[[_generateSettingsLink()]]"
           title="Settings"
+          aria-label="Settings"
+          role="button"
         >
           <iron-icon icon="gr-icons:settings"></iron-icon>
         </a>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 02b4efd..39d00069 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -171,6 +171,14 @@
         type: Number,
         value: 1,
       },
+      /**
+       * Invisible label for input element. This label is exposed to
+       * screen readers by nested element
+       */
+      label: {
+        type: String,
+        value: '',
+      },
     };
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
index e26f8a3..b0ef7af 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
@@ -30,6 +30,7 @@
   </style>
   <form>
     <gr-autocomplete
+      label="[[label]]"
       show-search-icon=""
       id="searchInput"
       text="{{_inputVal}}"
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index f6221c8..2d1212b 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -62,6 +62,14 @@
           return this._fetchAccounts.bind(this);
         },
       },
+      /**
+       * Invisible label for input element. This label is exposed to
+       * screen readers by nested element
+       */
+      label: {
+        type: String,
+        value: '',
+      },
     };
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
index bb741ce..d490308 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
@@ -20,6 +20,7 @@
   <style include="shared-styles"></style>
   <gr-search-bar
     id="search"
+    label="[[label]]"
     value="{{searchQuery}}"
     on-handle-search="_handleSearch"
     project-suggestions="[[_projectSuggestions]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
index 1c4edec..c9d5d78 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
@@ -211,8 +211,8 @@
       null;
   }
 
-  getContentByLine(lineNumber, opt_side, opt_root) {
-    return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
+  getContentTdByLine(lineNumber, opt_side, opt_root) {
+    return this._builder.getContentTdByLine(lineNumber, opt_side, opt_root);
   }
 
   _getDiffRowByChild(child) {
@@ -222,14 +222,14 @@
     return child;
   }
 
-  getContentByLineEl(lineEl) {
+  getContentTdByLineEl(lineEl) {
     if (!lineEl) return;
     const line = lineEl.getAttribute('data-value');
     const side = this.getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
     const row = dom(this._getDiffRowByChild(lineEl));
-    return this.getContentByLine(line, side, row);
+    return this.getContentTdByLine(line, side, row);
   }
 
   getLineElByNumber(lineNumber, opt_side) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
index 67418e2..057401b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
@@ -973,22 +973,24 @@
       assert.equal(actual.textContent, diff.content[1].b[0]);
     });
 
-    test('getContentByLineEl works both with button and td', () => {
+    test('getContentTdByLineEl works both with button and td', () => {
       const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
 
       const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
       const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentLeft = diffRow.querySelectorAll('.contentText')[0];
+      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
 
       const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
       const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentRight = diffRow.querySelectorAll('.contentText')[1];
+      const contentTdRight = diffRow.querySelectorAll('.content')[1];
 
-      assert.equal(element.getContentByLineEl(lineNumTdLeft), contentLeft);
-      assert.equal(element.getContentByLineEl(lineNumButtonLeft), contentLeft);
-      assert.equal(element.getContentByLineEl(lineNumTdRight), contentRight);
+      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
       assert.equal(
-          element.getContentByLineEl(lineNumButtonRight), contentRight);
+          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
     });
 
     test('findLinesByRange', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 74a4591..1cabd82 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -146,12 +146,18 @@
   return groups;
 };
 
-GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
-    opt_root) {
+GrDiffBuilder.prototype.getContentTdByLine = function(
+    lineNumber, opt_side, opt_root) {
   const root = dom(opt_root || this._outputEl);
   const sideSelector = opt_side ? ('.' + opt_side) : '';
   return root.querySelector('td.lineNum[data-value="' + lineNumber +
-      '"]' + sideSelector + ' ~ td.content .contentText');
+    '"]' + sideSelector + ' ~ td.content');
+};
+
+GrDiffBuilder.prototype.getContentByLine = function(
+    lineNumber, opt_side, opt_root) {
+  return this.getContentTdByLine(lineNumber, opt_side, opt_root)
+      .querySelector('.contentText');
 };
 
 /**
@@ -309,15 +315,20 @@
   }
 
   if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
-    const button = this._createElement('button');
-    button.tabIndex = -1;
-    td.appendChild(button);
-
     // Both td and button need a number of classes/attributes for various
     // selectors to work.
     this._decorateLineEl(td, number, side);
     td.classList.add('lineNum');
+
+    if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+      return td;
+    }
+
+    const button = this._createElement('button');
+    td.appendChild(button);
+    button.tabIndex = -1;
     this._decorateLineEl(button, number, side);
+
     button.classList.add('lineNumButton');
 
     button.textContent = number === 'FILE' ? 'File' : number;
@@ -357,22 +368,26 @@
   }
   td.classList.add(line.type);
 
-  const lineLimit =
-      !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
+  if (line.beforeNumber !== 'FILE') {
+    const lineLimit =
+        !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
+    const contentText =
+        this._formatText(line.text, this._prefs.tab_size, lineLimit);
 
-  const contentText =
-      this._formatText(line.text, this._prefs.tab_size, lineLimit);
-  if (opt_side) {
-    contentText.setAttribute('data-side', opt_side);
-  }
-
-  for (const layer of this.layers) {
-    if (typeof layer.annotate == 'function') {
-      layer.annotate(contentText, lineNumberEl, line);
+    if (opt_side) {
+      contentText.setAttribute('data-side', opt_side);
     }
-  }
 
-  td.appendChild(contentText);
+    for (const layer of this.layers) {
+      if (typeof layer.annotate == 'function') {
+        layer.annotate(contentText, lineNumberEl, line);
+      }
+    }
+
+    td.appendChild(contentText);
+  } else {
+    td.classList.add('file');
+  }
 
   return td;
 };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index c5dab43..229ae43 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -290,11 +290,8 @@
     if (!line) {
       return;
     }
-    const contentText = this.diffBuilder.getContentByLineEl(lineEl);
-    if (!contentText) {
-      return;
-    }
-    const contentTd = contentText.parentElement;
+    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentText = contentTd.querySelector('.contentText');
     if (!contentTd.contains(node)) {
       node = contentText;
       column = 0;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 86f1505..ec7e6d2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -267,9 +267,9 @@
         contentTd,
         contentText,
       });
-      builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
       builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-      builder.getContentByLine.withArgs(line, side).returns(contentText);
+      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
       builder.getSideByLineEl.withArgs(lineEl).returns(side);
       return contentText;
     };
@@ -296,8 +296,8 @@
       });
       diff = element.querySelector('#diffTable');
       builder = {
-        getContentByLine: sandbox.stub(),
-        getContentByLineEl: sandbox.stub(),
+        getContentTdByLine: sandbox.stub(),
+        getContentTdByLineEl: sandbox.stub(),
         getLineElByChild,
         getLineNumberByChild: sandbox.stub(),
         getSideByLineEl: sandbox.stub(),
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 b367265..cb299de 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
@@ -218,6 +218,11 @@
         notify: true,
       },
 
+      _fetchDiffPromise: {
+        type: Object,
+        value: null,
+      },
+
       /** @type {?Object} */
       _blame: {
         type: Object,
@@ -367,7 +372,7 @@
                     event.detail.contentRendered;
               if (needsSyntaxHighlighting) {
                 this.reporting.time(TimingLabel.SYNTAX);
-                this.$.syntaxLayer.process().then(() => {
+                this.$.syntaxLayer.process().finally(() => {
                   this.reporting.timeEnd(TimingLabel.SYNTAX);
                   this.reporting.timeEnd(TimingLabel.TOTAL);
                   resolve();
@@ -454,6 +459,7 @@
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
     this.$.diff.cancel();
+    this.$.syntaxLayer.cancel();
   }
 
   /** @return {!Array<!HTMLElement>} */
@@ -534,8 +540,21 @@
         !this.noAutoRender;
   }
 
+  // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
+  prefetchDiff() {
+    if (!!this.changeNum && !!this.patchRange && !!this.path
+        && this._fetchDiffPromise === null) {
+      this._fetchDiffPromise = this._getDiff();
+    }
+  }
+
   /** @return {!Promise<!Object>} */
   _getDiff() {
+    if (this._fetchDiffPromise !== null) {
+      const fetchDiffPromise = this._fetchDiffPromise;
+      this._fetchDiffPromise = null;
+      return fetchDiffPromise;
+    }
     // Wrap the diff request in a new promise so that the error handler
     // rejects the promise, allowing the error to be handled in the .catch.
     return new Promise((resolve, reject) => {
@@ -792,6 +811,7 @@
     }
     threadEl.changeNum = this.changeNum;
     threadEl.patchNum = thread.patchNum;
+    threadEl.showPatchset = false;
     threadEl.lineNum = thread.lineNum;
     const rootIdChangedListener = changeEvent => {
       thread.rootId = changeEvent.detail.value;
@@ -905,6 +925,7 @@
       return;
     }
 
+    this._fetchDiffPromise = null;
     if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
         !noRenderOnPrefsChange) {
       this.reload();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index b8f26b2..342c7ec 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -445,6 +445,19 @@
       });
     });
 
+    test('prefetch getDiff', done => {
+      const diffRestApiStub = sandbox.stub(element.$.restAPI, 'getDiff')
+          .returns(Promise.resolve({content: []}));
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+      element.prefetchDiff();
+      element._getDiff().then(() =>{
+        assert.isTrue(diffRestApiStub.calledOnce);
+        done();
+      });
+    });
+
     test('_getDiff handles null diff responses', done => {
       stub('gr-rest-api-interface', {
         getDiff() { return Promise.resolve(null); },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 608e898..cfbe481 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -24,7 +24,7 @@
 import {htmlTemplate} from './gr-diff-selection_html.js';
 import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
 import {GrRangeNormalizer} from '../gr-diff-highlight/gr-range-normalizer.js';
-import {util} from '../../../scripts/util.js';
+import {querySelectorAll} from '../../../utils/dom-util.js';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -203,7 +203,7 @@
   }
 
   _getSelection() {
-    const diffHosts = util.querySelectorAll(document.body, 'gr-diff');
+    const diffHosts = querySelectorAll(document.body, 'gr-diff');
     if (!diffHosts.length) return window.getSelection();
 
     const curDiffHost = diffHosts.find(diffHost => {
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 415ce1e..19abdaf 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
@@ -298,6 +298,8 @@
       [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
       [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
       [this.Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+      [this.Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+          '_handleToggleHideAllCommentThreads',
 
       // Final two are actually handled by gr-comment-thread.
       [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
@@ -740,6 +742,7 @@
     this._initCursor(this.params);
 
     this._changeNum = value.changeNum;
+    this.classList.remove('hideComments');
     this._path = value.path;
     this._patchRange = {
       patchNum: value.patchNum,
@@ -798,6 +801,8 @@
 
     promises.push(this._getChangeEdit(this._changeNum));
 
+    this.$.diffHost.cancel();
+    this.$.diffHost.clearDiffContent();
     this._loading = true;
     return Promise.all(promises)
         .then(r => {
@@ -1275,6 +1280,12 @@
     this._toggleBlame();
   }
 
+  _handleToggleHideAllCommentThreads(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e)) { return; }
+    this.toggleClass('hideComments');
+  }
+
   _computeBlameLoaderClass(isImageDiff, path) {
     return !this.isMagicPath(path) && !isImageDiff ? 'show' : '';
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
index e735153..3d30f77 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
@@ -196,6 +196,9 @@
         }
       }
     }
+    :host(.hideComments) {
+      --gr-comment-thread-display: none;
+    }
   </style>
   <gr-fixed-panel
     class$="[[_computeContainerClass(_editMode)]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 98371dc..21d1144 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -71,6 +71,7 @@
     kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
+    kb.bindShortcut(kb.Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
     kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
     kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
     kb.bindShortcut(kb.Shortcut.TOGGLE_BLAME, 'b');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index bfe7325..0942646 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -433,7 +433,8 @@
     }
 
     return Array.from(
-        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'));
+        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'))
+        .filter(tr => tr.querySelector('button'));
   }
 
   /** @return {boolean} */
@@ -592,11 +593,7 @@
    * @param {!Object=} range
    */
   _createComment(lineEl, lineNum, side, range) {
-    const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-    if (!contentText) {
-      return;
-    }
-    const contentEl = contentText.parentElement;
+    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
     side = side ||
         this._getCommentSideByLineAndContent(lineEl, contentEl);
     const patchForNewThreads = this._getPatchNumByLineAndContent(
@@ -831,11 +828,7 @@
         const commentSide = threadEl.getAttribute('comment-side');
         const lineEl = this.$.diffBuilder.getLineElByNumber(
             lineNumString, commentSide);
-        const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-        if (!contentText) {
-          continue;
-        }
-        const contentEl = contentText.parentElement;
+        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
         const threadGroupEl = this._getOrCreateThreadGroup(
             contentEl, commentSide);
         // Create a slot for the thread and attach it to the thread group.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
index 55abd38..279f968 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
@@ -90,6 +90,16 @@
     .content {
       background-color: var(--diff-blank-background-color);
     }
+    /*
+      The file line, which has no contentText, add some margin before the first
+      comment. We cannot add padding the container because we only want it if
+      there is at least one comment thread, and the slotting makes :empty not
+      work as expected.
+     */
+    .content.file slot:first-child::slotted(.comment-thread) {
+      display: block;
+      margin-top: var(--spacing-xs);
+    }
     .contentText {
       background-color: var(--view-background-color);
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index d497b9a..a10db97 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -21,7 +21,7 @@
 import './gr-diff.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {util} from '../../../scripts/util.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import '@polymer/paper-button/paper-button.js';
@@ -82,14 +82,14 @@
     element = basicFixture.instantiate();
     element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
     flushAsynchronousOperations();
-    assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
+    assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
   });
 
   test('line limit without line_wrapping', () => {
     element = basicFixture.instantiate();
     element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
     flushAsynchronousOperations();
-    assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
+    assert.isNotOk(getComputedStyleValue('--line-limit', element));
   });
 
   suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 3bf3697..c00c0fb 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -25,6 +25,7 @@
 import {htmlTemplate} from './gr-patch-range-select_html.js';
 import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {appContext} from '../../../services/app-context.js';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -78,6 +79,11 @@
     ];
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   _getShaForPatch(patch) {
     return patch.sha.substring(0, 10);
   }
@@ -285,8 +291,14 @@
     const target = dom(e).localTarget;
 
     if (target === this.$.patchNumDropdown) {
+      if (detail.patchNum === e.detail.value) return;
+      this.reporting.reportInteraction('right-patchset-changed',
+          {previous: detail.patchNum, current: e.detail.value});
       detail.patchNum = e.detail.value;
     } else {
+      if (detail.basePatchNum === e.detail.value) return;
+      this.reporting.reportInteraction('left-patchset-changed',
+          {previous: detail.basePatchNum, current: e.detail.value});
       detail.basePatchNum = e.detail.value;
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
index 1d4b440..cc11cfa 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
@@ -51,7 +51,7 @@
       }
     }
   </style>
-  <span class="patchRange">
+  <span class="patchRange" aria-label="patch range starts with">
     <gr-dropdown-list
       id="basePatchDropdown"
       value="[[basePatchNum]]"
@@ -67,8 +67,8 @@
       >
     </template>
   </span>
-  <span class="arrow">→</span>
-  <span class="patchRange">
+  <span aria-hidden="true" class="arrow">→</span>
+  <span class="patchRange" aria-label="patch range ends with">
     <gr-dropdown-list
       id="patchNumDropdown"
       value="[[patchNum]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index ac5804f..1a0bbd9 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -249,7 +249,7 @@
   process() {
     // Cancel any still running process() calls, because they append to the
     // same _baseRanges and _revisionRanges fields.
-    this._cancel();
+    this.cancel();
 
     // Discard existing ranges.
     this._baseRanges = [];
@@ -319,7 +319,7 @@
   /**
    * Cancel any asynchronous syntax processing jobs.
    */
-  _cancel() {
+  cancel() {
     if (this._processHandle != null) {
       this.cancelAsync(this._processHandle);
       this._processHandle = null;
@@ -330,7 +330,7 @@
   }
 
   _diffChanged() {
-    this._cancel();
+    this.cancel();
     this._baseRanges = [];
     this._revisionRanges = [];
   }
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index 9c2c6ed..4915eda 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -30,6 +30,7 @@
 import './edit/gr-editor-view/gr-editor-view.js';
 import './plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import './plugins/gr-endpoint-param/gr-endpoint-param.js';
+import './plugins/gr-endpoint-slot/gr-endpoint-slot.js';
 import './plugins/gr-external-style/gr-external-style.js';
 import './plugins/gr-plugin-host/gr-plugin-host.js';
 import './settings/gr-cla-view/gr-cla-view.js';
@@ -349,6 +350,8 @@
         this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
     this.bindShortcut(
         this.Shortcut.TOGGLE_BLAME, 'b');
+    this.bindShortcut(
+        this.Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
 
     this.bindShortcut(
         this.Shortcut.OPEN_FIRST_FILE, ']');
@@ -496,6 +499,10 @@
     }
   }
 
+  handleShowKeyboardShortcuts() {
+    this.$.keyboardShortcuts.open();
+  }
+
   _showKeyboardShortcuts(e) {
     // same shortcut should close the dialog if pressed again
     // when dialog is open
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.js b/polygerrit-ui/app/elements/gr-app-element_html.js
index ad47d52..75295bc 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.js
+++ b/polygerrit-ui/app/elements/gr-app-element_html.js
@@ -104,6 +104,7 @@
       id="mainHeader"
       search-query="{{params.query}}"
       on-mobile-search="_mobileSearchToggle"
+      on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
       login-url="[[_loginUrl]]"
     >
     </gr-main-header>
@@ -111,6 +112,7 @@
   <main>
     <gr-smart-search
       id="search"
+      label="Search for changes"
       search-query="{{params.query}}"
       hidden="[[!mobileSearch]]"
     >
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
index 52c4f4e..3f8aa44 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
@@ -14,13 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-attribute-helper">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrAttributeHelper(element) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
index ea60dc6..89d8ec2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
@@ -14,13 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrChangeMetadataApi(plugin) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index 41d5fd5..4822163 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -14,14 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-dom-hooks">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
 /** @constructor */
 export function GrDomHooksManager(plugin) {
@@ -61,13 +54,18 @@
 }
 
 GrDomHook.prototype._createPlaceholder = function(hookName) {
-  Polymer({
-    is: hookName,
-    properties: {
-      plugin: Object,
-      content: Object,
-    },
-  });
+  class HookPlaceholder extends PolymerElement {
+    static get is() { return hookName; }
+
+    static get properties() {
+      return {
+        plugin: Object,
+        content: Object,
+      };
+    }
+  }
+
+  customElements.define(HookPlaceholder.is, HookPlaceholder);
 };
 
 GrDomHook.prototype.handleInstanceDetached = function(instance) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index 9bfb7da..4991770 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -74,12 +74,22 @@
     });
   }
 
-  _initDecoration(name, plugin) {
+  _initDecoration(name, plugin, slot) {
     const el = document.createElement(name);
     return this._initProperties(el, plugin,
         this.getContentChildren().find(
             el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
-        .then(el => this._appendChild(el));
+        .then(el => {
+          const slotEl = slot ?
+            dom(this).querySelector(`gr-endpoint-slot[name=${slot}]`) :
+            null;
+          if (slot && slotEl) {
+            slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
+          } else {
+            this._appendChild(el);
+          }
+          return el;
+        });
   }
 
   _initReplacement(name, plugin) {
@@ -133,7 +143,7 @@
     return dom(this.root).appendChild(el);
   }
 
-  _initModule({moduleName, plugin, type, domHook}) {
+  _initModule({moduleName, plugin, type, domHook, slot}) {
     const name = plugin.getPluginName() + '.' + moduleName;
     if (this._initializedPlugins.get(name)) {
       return;
@@ -141,7 +151,7 @@
     let initPromise;
     switch (type) {
       case 'decorate':
-        initPromise = this._initDecoration(moduleName, plugin);
+        initPromise = this._initDecoration(moduleName, plugin, slot);
         break;
       case 'replace':
         initPromise = this._initReplacement(moduleName, plugin);
@@ -162,16 +172,18 @@
     super.ready();
     this._endpointCallBack = this._initModule.bind(this);
     pluginEndpoints.onNewEndpoint(this.name, this._endpointCallBack);
-    pluginLoader.awaitPluginsLoaded()
-        .then(() => Promise.all(
-            pluginEndpoints.getPlugins(this.name).map(
-                pluginUrl => this._import(pluginUrl)))
-        )
-        .then(() =>
-          pluginEndpoints
-              .getDetails(this.name)
-              .forEach(this._initModule, this)
-        );
+    if (this.name) {
+      pluginLoader.awaitPluginsLoaded()
+          .then(() => Promise.all(
+              pluginEndpoints.getPlugins(this.name).map(
+                  pluginUrl => this._import(pluginUrl)))
+          )
+          .then(() =>
+            pluginEndpoints
+                .getDetails(this.name)
+                .forEach(this._initModule, this)
+          );
+    }
   }
 }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index d7eafa5..890a457 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -29,6 +29,10 @@
     <div>
       <gr-endpoint-decorator name="first">
         <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
+        <p>
+          <span>test slot</span>
+          <gr-endpoint-slot name="test"></gr-endpoint-slot>
+        </p>
       </gr-endpoint-decorator>
       <gr-endpoint-decorator name="second">
         <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
@@ -44,6 +48,7 @@
 import '../../../test/common-test-setup.js';
 import './gr-endpoint-decorator.js';
 import '../gr-endpoint-param/gr-endpoint-param.js';
+import '../gr-endpoint-slot/gr-endpoint-slot.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {resetPlugins} from '../../../test/test-utils.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
@@ -56,6 +61,7 @@
   let sandbox;
   let plugin;
   let decorationHook;
+  let decorationHookWithSlot;
   let replacementHook;
 
   setup(done => {
@@ -69,6 +75,11 @@
         'http://some/plugin/url.html');
     // Decoration
     decorationHook = plugin.registerCustomComponent('first', 'some-module');
+    decorationHookWithSlot = plugin.registerCustomComponent(
+        'first',
+        'some-module-2',
+        {slot: 'test'}
+    );
     // Replacement
     replacementHook = plugin.registerCustomComponent(
         'second', 'other-module', {replace: true});
@@ -100,15 +111,34 @@
     const [module] = modules;
     assert.isOk(module);
     assert.equal(module['someparam'], 'barbar');
-    return decorationHook.getLastAttached().then(element => {
-      assert.strictEqual(element, module);
-    })
+    return decorationHook.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
         .then(() => {
           element.remove();
           assert.equal(decorationHook.getAllAttached().length, 0);
         });
   });
 
+  test('decoration with slot', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = [...dom(element).querySelectorAll('p > some-module-2')];
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'barbar');
+    return decorationHookWithSlot.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
+        .then(() => {
+          element.remove();
+          assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
+        });
+  });
+
   test('replacement', () => {
     const element =
         container.querySelector('gr-endpoint-decorator[name="second"]');
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
similarity index 62%
copy from polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
copy to polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
index cf934b0..9ee9c3d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
@@ -14,21 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-  </style>
-  <h3>[[title]]</h3>
-  <gr-button
-    title$="[[tooltip]]"
-    disabled$="[[disabled]]"
-    on-click="_onCommandTap"
-  >
-    [[title]]
-  </gr-button>
-`;
+class GrEndpointSlot extends PolymerElement {
+  static get is() { return 'gr-endpoint-slot'; }
+
+  static get properties() {
+    return {
+      name: String,
+    };
+  }
+}
+
+customElements.define(GrEndpointSlot.is, GrEndpointSlot);
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
index da85b9e..466f84a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
@@ -14,13 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-event-helper">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrEventHelper(element) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
index 738b276..e2dd047 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -16,13 +16,6 @@
  */
 import './gr-plugin-popup.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-popup-interface">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /**
  * Plugin popup API.
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
index 752570f..1a2cd28 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
@@ -14,20 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../admin/gr-repo-command/gr-repo-command.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-Polymer({
-  _template: html`
-    <gr-repo-command title="[[title]]">
-    </gr-repo-command>
-`,
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-repo-command_html.js';
 
-  is: 'gr-plugin-repo-command',
+class GrPluginRepoCommand extends PolymerElement {
+  static get is() {
+    return 'gr-plugin-repo-command';
+  }
 
-  properties: {
-    title: String,
-    repoName: String,
-    config: Object,
-  },
-});
+  static get properties() {
+    return {
+      title: String,
+      repoName: String,
+      config: Object,
+    };
+  }
+
+  static get template() {
+    return htmlTemplate;
+  }
+
+  _handleClick() {
+    this.dispatchEvent(
+        new CustomEvent('command-tap', {composed: true, bubbles: true})
+    );
+  }
+}
+
+customElements.define(GrPluginRepoCommand.is, GrPluginRepoCommand);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
similarity index 86%
rename from polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
rename to polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
index cf934b0..1e5088b3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.js
@@ -24,11 +24,5 @@
     }
   </style>
   <h3>[[title]]</h3>
-  <gr-button
-    title$="[[tooltip]]"
-    disabled$="[[disabled]]"
-    on-click="_onCommandTap"
-  >
-    [[title]]
-  </gr-button>
+  <gr-button on-click="_handleClick">[[title]]</gr-button>
 `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
index 445356d..04408f8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
@@ -15,13 +15,6 @@
  * limitations under the License.
  */
 import './gr-plugin-repo-command.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-repo-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrRepoApi(plugin) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
index 32ae959..1af91fd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
@@ -73,13 +73,12 @@
       const pluginCommand = element.shadowRoot
           .querySelector('gr-plugin-repo-command');
       assert.isOk(pluginCommand);
-      const command = pluginCommand.shadowRoot
-          .querySelector('gr-repo-command');
-      assert.isOk(command);
-      assert.equal(command.title, 'foo');
+      const btn = pluginCommand.shadowRoot
+          .querySelector('gr-button');
+      assert.isOk(btn);
+      assert.equal(btn.textContent, 'foo');
       assert.isFalse(tapStub.called);
-      MockInteractions.tap(command.shadowRoot
-          .querySelector('gr-button'));
+      MockInteractions.tap(btn);
       assert.isTrue(tapStub.called);
       done();
     });
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
index 35396cf..8050cd6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
@@ -16,13 +16,6 @@
  */
 import '../../settings/gr-settings-view/gr-settings-item.js';
 import '../../settings/gr-settings-view/gr-settings-menu-item.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-settings-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrSettingsApi(plugin) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
index ae8b8ab..1e37603 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
@@ -14,10 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-Polymer({
-  _template: html`
+
+class CustomPluginHeader extends PolymerElement {
+  static get is() {
+    return 'gr-custom-plugin-header';
+  }
+
+  static get properties() {
+    return {
+      logoUrl: String,
+      title: String,
+    };
+  }
+
+  static get template() {
+    return html`
     <style>
       img {
         width: 1em;
@@ -32,12 +45,8 @@
       <img src="[[logoUrl]]" hidden\$="[[!logoUrl]]">
       <span class="title">[[title]]</span>
     </span>
-`,
+`;
+  }
+}
 
-  is: 'gr-custom-plugin-header',
-
-  properties: {
-    logoUrl: String,
-    title: String,
-  },
-});
+customElements.define(CustomPluginHeader.is, CustomPluginHeader);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
index be427ec..48b14d3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
@@ -15,13 +15,6 @@
  * limitations under the License.
  */
 import './gr-custom-plugin-header.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-theme-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrThemeApi(plugin) {
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
index 2d371e2..b1a951b7 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
@@ -61,8 +61,8 @@
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
   <main>
-    <h1>New Contributor Agreement</h1>
-    <h3>Select an agreement type:</h3>
+    <h1 class="heading-1">New Contributor Agreement</h1>
+    <h3 class="heading-3">Select an agreement type:</h3>
     <template
       is="dom-repeat"
       items="[[_serverConfig.auth.contributor_agreements]]"
@@ -92,7 +92,7 @@
       id="claNewAgreement"
       class$="[[_computeShowAgreementsClass(_showAgreements)]]"
     >
-      <h3 class="smallHeading">Review the agreement:</h3>
+      <h3 class="heading-3">Review the agreement:</h3>
       <div id="agreementsUrl" class="agreementsUrl">
         <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
           Please review the agreement.</a
@@ -101,7 +101,7 @@
       <div
         class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]"
       >
-        <h3 class="smallHeading">Complete the agreement:</h3>
+        <h3 class="heading-3">Complete the agreement:</h3>
         <iron-input
           bind-value="{{_agreementsText}}"
           placeholder="Enter 'I agree' here"
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
index e26faab..26200d4 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
@@ -23,6 +23,6 @@
       margin-bottom: var(--spacing-xxl);
     }
   </style>
-  <h2 id="[[anchor]]">[[title]]</h2>
+  <h2 id="[[anchor]]" class="heading-2">[[title]]</h2>
   <slot></slot>
 `;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
index b22d412..e32a551 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
@@ -21,6 +21,12 @@
     :host {
       color: var(--primary-text-color);
     }
+    h2 {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h2);
+      font-weight: var(--font-weight-h2);
+      line-height: var(--line-height-h2);
+    }
     .newEmailInput {
       width: 20em;
     }
@@ -94,7 +100,7 @@
       </ul>
     </gr-page-nav>
     <main class="gr-form-styles">
-      <h1>User Settings</h1>
+      <h1 class="heading-1">User Settings</h1>
       <section class="darkToggle">
         <div class="toggle">
           <paper-toggle-button
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
index abe5206..6f5f9e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
@@ -158,10 +158,10 @@
   }
 
   _handleAdd(e) {
-    this._addAccountItem(e.detail.value);
+    this.addAccountItem(e.detail.value);
   }
 
-  _addAccountItem(item) {
+  addAccountItem(item) {
     // Append new account or group to the accounts property. We add our own
     // internal properties to the account/group here, so we clone the object
     // to avoid cluttering up the shared change object.
@@ -248,11 +248,11 @@
 
   _handleRemove(e) {
     const toRemove = e.detail.account;
-    this._removeAccount(toRemove);
+    this.removeAccount(toRemove);
     this.$.entry.focus();
   }
 
-  _removeAccount(toRemove) {
+  removeAccount(toRemove) {
     if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
       return;
     }
@@ -286,7 +286,7 @@
     }
     switch (e.detail.keyCode) {
       case 8: // Backspace
-        this._removeAccount(this.accounts[this.accounts.length - 1]);
+        this.removeAccount(this.accounts[this.accounts.length - 1]);
         break;
       case 37: // Left arrow
         if (this.accountChips[this.accountChips.length - 1]) {
@@ -305,7 +305,7 @@
       case 13: // Enter
       case 32: // Spacebar
       case 46: // Delete
-        this._removeAccount(chip.account);
+        this.removeAccount(chip.account);
         // Splice from this array to avoid inconsistent ordering of
         // event handling.
         chips.splice(index, 1);
@@ -345,7 +345,7 @@
   submitEntryText() {
     const text = this.$.entry.getText();
     if (!text.length) { return true; }
-    const wasSubmitted = this._addAccountItem(text);
+    const wasSubmitted = this.addAccountItem(text);
     if (wasSubmitted) { this.$.entry.clear(); }
     return wasSubmitted;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
index 2efc6e8..41158a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
@@ -351,7 +351,7 @@
     element.readonly = true;
     const acct = makeAccount();
     element.accounts = [acct];
-    element._removeAccount(acct);
+    element.removeAccount(acct);
     assert.equal(element.accounts.length, 1);
   });
 
@@ -537,7 +537,7 @@
       element.accounts = [makeAccount(), makeAccount()];
       flush(() => {
         const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
-        const removeSpy = sandbox.spy(element, '_removeAccount');
+        const removeSpy = sandbox.spy(element, 'removeAccount');
         MockInteractions.pressAndReleaseKeyOn(
             element.accountChips[0], 8); // Backspace
         assert.isTrue(focusSpy.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index e6a809e..4cf56a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -184,6 +184,14 @@
         type: Boolean,
         value: false,
       },
+      /**
+       * Invisible label for input element. This label is exposed to
+       * screen readers by paper-input
+       */
+      label: {
+        type: String,
+        value: '',
+      },
 
       /** The DOM element of the selected suggestion. */
       _selected: Object,
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
index eae8741..c0e8abf 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
@@ -66,6 +66,12 @@
         height: 0;
         display: none;
       }
+      /* Hide label for input. The label is still visible for
+      screen readers. Workaround found at:
+      https://github.com/PolymerElements/paper-input/issues/478 */
+      --paper-input-container-label: {
+        display: none;
+      }
     }
     paper-input.warnUncommitted {
       --paper-input-container-input: {
@@ -85,6 +91,7 @@
     on-focus="_onInputFocus"
     on-blur="_onInputBlur"
     autocomplete="off"
+    label="[[label]]"
   >
     <!-- prefix as attribute is required to for polymer 1 -->
     <div slot="prefix" prefix="">
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
similarity index 93%
rename from polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
rename to polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
index 6dd5a97..eb05b90 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
@@ -1,40 +1,28 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reviewer-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-autocomplete no-debounce></gr-autocomplete>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
+import '../../../test/common-test-setup-karma.js';
 import './gr-autocomplete.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {dom, flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-autocomplete no-debounce></gr-autocomplete>`);
+
 suite('gr-autocomplete tests', () => {
   let element;
   let sandbox;
@@ -44,7 +32,7 @@
   };
 
   setup(() => {
-    element = fixture('basic');
+    element = basicFixture.instantiate();
     sandbox = sinon.sandbox.create();
   });
 
@@ -609,4 +597,3 @@
     });
   });
 });
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 6ef4807..346d84e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -23,7 +23,7 @@
 import {htmlTemplate} from './gr-button_html.js';
 import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
 import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {util} from '../../../scripts/util.js';
+import {getEventPath} from '../../../utils/dom-util.js';
 import {appContext} from '../../../services/app-context.js';
 
 /**
@@ -65,6 +65,11 @@
         value: false,
         reflectToAttribute: true,
       },
+      ariaDisabled: {
+        type: Boolean,
+        computed: '_computeDisabled(disabled, loading)',
+        reflectToAttribute: true,
+      },
 
       _disabled: {
         type: Boolean,
@@ -108,7 +113,7 @@
     }
 
     this.reporting.reportInteraction('button-click',
-        {path: util.getEventPath(e)});
+        {path: getEventPath(e)});
   }
 
   _disabledChanged(disabled) {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index 886f0c1..dac1755 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -53,6 +53,10 @@
     return `gr-icons:star${starred ? '' : '-border'}`;
   }
 
+  _computeAriaLabel(starred) {
+    return starred ? 'Unstar this change' : 'Star this change';
+  }
+
   toggleStar() {
     const newVal = !this.change.starred;
     this.set('change.starred', newVal);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
index f723717a..3b7b1af 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
@@ -37,7 +37,11 @@
       );
     }
   </style>
-  <button aria-label="Change star" on-click="toggleStar">
+  <button
+    role="checkbox"
+    aria-label="[[_computeAriaLabel(change.starred)]]]"
+    on-click="toggleStar"
+  >
     <iron-icon
       class$="[[_computeStarClass(change.starred)]]"
       icon$="[[_computeStarIcon(change.starred)]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
index 904ef1d..38e620f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
@@ -25,43 +25,43 @@
       white-space: nowrap;
     }
     :host(.merged) .chip {
-      background-color: #5b9d52;
-      color: #5b9d52;
+      background-color: var(--status-merged);
+      color: var(--status-merged);
     }
     :host(.abandoned) .chip {
-      background-color: #afafaf;
-      color: #afafaf;
+      background-color: var(--status-abandoned);
+      color: var(--status-abandoned);
     }
     :host(.wip) .chip {
-      background-color: #8f756c;
-      color: #8f756c;
+      background-color: var(--status-wip);
+      color: var(--status-wip);
     }
     :host(.private) .chip {
-      background-color: #c17ccf;
-      color: #c17ccf;
+      background-color: var(--status-private);
+      color: var(--status-private);
     }
     :host(.merge-conflict) .chip {
-      background-color: #dc5c60;
-      color: #dc5c60;
+      background-color: var(--status-conflict);
+      color: var(--status-conflict);
     }
     :host(.active) .chip {
-      background-color: #29b6f6;
-      color: #29b6f6;
+      background-color: var(--status-active);
+      color: var(--status-active);
     }
     :host(.ready-to-submit) .chip {
-      background-color: #e10ca3;
-      color: #e10ca3;
+      background-color: var(--status-ready);
+      color: var(--status-ready);
     }
     :host(.custom) .chip {
-      background-color: #825cc2;
-      color: #825cc2;
+      background-color: var(--status-custom);
+      color: var(--status-custom);
     }
     :host([flat]) .chip {
       background-color: transparent;
       padding: 0;
     }
     :host(:not([flat])) .chip {
-      color: white;
+      color: var(--status-text-color);
     }
   </style>
   <gr-tooltip-content
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index 0e2c8a3..fa686da 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -155,6 +155,14 @@
         value: false,
         reflectToAttribute: true,
       },
+      showFileName: {
+        type: Boolean,
+        value: true,
+      },
+      showPatchset: {
+        type: Boolean,
+        value: true,
+      },
     };
   }
 
@@ -221,6 +229,11 @@
         {detail: {rootId: this.rootId}, bubbles: false}));
   }
 
+  _getDiffUrlForPath(path) {
+    return GerritNav.getUrlForDiffById(this.changeNum, this.projectName, path,
+        this.patchNum);
+  }
+
   _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
     return GerritNav.getUrlForDiffById(changeNum,
         projectName, path, patchNum,
@@ -232,8 +245,24 @@
   }
 
   _computeDisplayPath(path) {
-    const lineString = this.lineNum ? `#${this.lineNum}` : '';
-    return this.computeDisplayPath(path) + lineString;
+    const displayPath = this.computeDisplayPath(path);
+    if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return `Patchset ${this.patchNum}`;
+    }
+    return displayPath;
+  }
+
+  _computeDisplayLine() {
+    if (this.lineNum) return `#${this.lineNum}`;
+    // If range is set, then lineNum equals the end line of the range.
+    if (!this.lineNum && !this.range) {
+      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return '';
+      }
+      return 'FILE';
+    }
+    if (this.range) return `#${this.range.end_line}`;
+    return '';
   }
 
   _getLoggedIn() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
index 88f0ff6..837a58f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
@@ -37,7 +37,7 @@
     #container {
       background-color: var(--comment-background-color);
       color: var(--comment-text-color);
-      display: block;
+      display: var(--gr-comment-thread-display, block);
       margin: 0 var(--spacing-s) var(--spacing-s);
       white-space: normal;
       box-shadow: var(--elevation-level-2);
@@ -72,19 +72,29 @@
       margin-left: var(--spacing-m);
       font-style: italic;
     }
+    .fileName {
+      padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+    }
   </style>
   <template is="dom-if" if="[[showFilePath]]">
+    <template is="dom-if" if="[[showFileName]]">
+      <div class="fileName">
+        <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
+          <span> [[_computeDisplayPath(path)]] </span>
+        </template>
+        <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
+          <a href$="[[_getDiffUrlForPath(path)]]">
+            [[_computeDisplayPath(path)]]
+          </a>
+        </template>
+      </div>
+    </template>
     <div class="pathInfo">
-      <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
-        <span>Patchset Comment</span>
-        <span class="descriptionText">Patchset [[patchNum]]</span>
-      </template>
       <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
         <a
           href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayPath(path)]]</a
+          >[[_computeDisplayLine()]]</a
         >
-        <span class="descriptionText">Patchset [[patchNum]]</span>
       </template>
     </div>
   </template>
@@ -106,6 +116,7 @@
         patch-num="[[patchNum]]"
         draft="[[_isDraft(comment)]]"
         show-actions="[[_showActions]]"
+        show-patchset="[[showPatchset]]"
         comment-side="[[comment.__commentSide]]"
         side="[[comment.side]]"
         project-config="[[_projectConfig]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
index eaa6547..e219b22 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
@@ -42,6 +42,7 @@
 import './gr-comment-thread.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
 
 suite('gr-comment-thread tests', () => {
   suite('basic test', () => {
@@ -215,17 +216,37 @@
       assert.notEqual(getComputedStyle(element.shadowRoot
           .querySelector('.pathInfo')).display,
       'none');
-      assert.isTrue(GerritNav.getUrlForDiffById.lastCall.calledWithExactly(
+      assert.isTrue(GerritNav.getUrlForDiffById.getCall(0).calledWithExactly(
           element.changeNum, element.projectName, element.path,
           element.patchNum, null, element.lineNum));
+      assert.isTrue(GerritNav.getUrlForDiffById.getCall(1).calledWithExactly(
+          element.changeNum, element.projectName, element.path,
+          element.patchNum));
     });
 
     test('_computeDisplayPath', () => {
-      const path = 'path/to/file';
+      let path = 'path/to/file';
       assert.equal(element._computeDisplayPath(path), 'path/to/file');
 
       element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.patchNum = '3';
+      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayPath(path), 'Patchset 3');
+    });
+
+    test('_computeDisplayLine', () => {
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.path = SpecialFilePath.COMMIT_MESSAGE;
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.lineNum = undefined;
+      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayLine(), '');
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index 6697880..02fec5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -213,6 +213,10 @@
         type: Boolean,
         value: false,
       },
+      showPatchset: {
+        type: Boolean,
+        value: true,
+      },
       _respectfulReviewTip: String,
       _respectfulTipDismissed: {
         type: Boolean,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
index b83cdf2..1d04969 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
@@ -60,7 +60,6 @@
     }
     .date {
       justify-content: flex-end;
-      margin-left: var(--spacing-m);
       text-align: right;
       white-space: nowrap;
     }
@@ -232,6 +231,10 @@
     .pointer {
       cursor: pointer;
     }
+    .patchset-text {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-s);
+    }
   </style>
   <div id="container" class="container">
     <div class="header" id="header" on-click="_handleToggleCollapsed">
@@ -270,13 +273,17 @@
       >
         <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
       </gr-button>
-      <span class="date" on-click="_handleAnchorClick">
+      <template is="dom-if" if="[[showPatchset]]">
+        <span class="patchset-text"> Patchset [[patchNum]]</span>
+      </template>
+      <span class="separator"></span>
+      <span class="date" tabindex="0" on-click="_handleAnchorClick">
         <gr-date-formatter
           has-tooltip=""
           date-str="[[comment.updated]]"
         ></gr-date-formatter>
       </span>
-      <div class="show-hide">
+      <div class="show-hide" tabindex="0">
         <label
           class="show-hide"
           aria-label="[[_computeShowHideAriaLabel(collapsed)]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
index b32f871..54b404d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
@@ -60,7 +60,7 @@
     }
   </style>
   <div class="container" on-keydown="_handleKeydown">
-    <header class="font-h3"><slot name="header"></slot></header>
+    <header class="heading-3"><slot name="header"></slot></header>
     <main>
       <div class="overflow-container">
         <slot name="main"></slot>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 8e7ea87..717275d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -292,7 +292,8 @@
     const item = this.items.find(item => item.id === id);
     if (id && !this.disabledIds.includes(id)) {
       if (item) {
-        this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+        this.dispatchEvent(new CustomEvent('tap-item',
+            {detail: item, bubbles: true, composed: true}));
       }
       this.dispatchEvent(new CustomEvent('tap-item-' + id));
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
index 9c8788b..bbf6a96 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
@@ -70,7 +70,7 @@
           <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
         </div>
         <div class="account">
-          <h3 class="name">[[account.name]]</h3>
+          <h3 class="name heading-3">[[account.name]]</h3>
           <div class="email">[[account.email]]</div>
         </div>
       </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
index bcb85e2..070ac02 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
@@ -100,6 +100,10 @@
       <g id="attention"><path d="M5.5 19 l9 0 c.67 0 1.27 -.33 1.63 -.84 L20.5 12 l-4.37 -6.16 c-.36 -.51 -.96 -.84 -1.63 -.84 l-9 0 L9 12 z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
       <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
+      <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
+      <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 388243c..0727397 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -39,12 +39,13 @@
   }
 };
 
-GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin,
-    endpoint, type, moduleName, domHook) {
+GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin, opts) {
+  const {endpoint, slot, type, moduleName, domHook} = opts;
   const existingModule = this._endpoints[endpoint].find(info =>
     info.plugin === plugin &&
       info.moduleName === moduleName &&
-      info.domHook === domHook
+      info.domHook === domHook &&
+      info.slot === slot
   );
   if (existingModule) {
     return existingModule;
@@ -55,6 +56,7 @@
       pluginUrl: plugin._url,
       type,
       domHook,
+      slot,
     };
     this._endpoints[endpoint].push(newModule);
     return newModule;
@@ -67,9 +69,12 @@
  * Dynamic plugins are registered to a specific prefix, such as
  * 'change-list-header'. These plugins are then fetched by prefix to determine
  * which endpoints to dynamically add to the page.
+ *
+ * @param {Object} plugin
+ * @param {Object} opts
  */
-GrPluginEndpoints.prototype.registerModule = function(plugin, endpoint, type,
-    moduleName, domHook, dynamicEndpoint) {
+GrPluginEndpoints.prototype.registerModule = function(plugin, opts) {
+  const {endpoint, dynamicEndpoint} = opts;
   if (dynamicEndpoint) {
     if (!this._dynamicPlugins[dynamicEndpoint]) {
       this._dynamicPlugins[dynamicEndpoint] = new Set();
@@ -79,8 +84,7 @@
   if (!this._endpoints[endpoint]) {
     this._endpoints[endpoint] = [];
   }
-  const moduleInfo = this._getOrCreateModuleInfo(plugin, endpoint, type,
-      moduleName, domHook);
+  const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
   if (pluginLoader.arePluginsLoaded() && this._callbacks[endpoint]) {
     this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index ec8710b..3494e99 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -48,11 +48,25 @@
     pluginApi.install(p => { pluginFoo = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/foo.html');
     instance.registerModule(
-        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
+        pluginFoo,
+        {
+          endpoint: 'a-place',
+          type: 'decorate',
+          moduleName: 'foo-module',
+          domHook,
+        }
+    );
     pluginApi.install(p => { pluginBar = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/bar.html');
     instance.registerModule(
-        pluginBar, 'a-place', 'style', 'bar-module', domHook);
+        pluginBar,
+        {
+          endpoint: 'a-place',
+          type: 'style',
+          moduleName: 'bar-module',
+          domHook,
+        }
+    );
     sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
   });
 
@@ -68,6 +82,7 @@
         pluginUrl: pluginFoo._url,
         type: 'decorate',
         domHook,
+        slot: undefined,
       },
       {
         moduleName: 'bar-module',
@@ -75,6 +90,7 @@
         pluginUrl: pluginBar._url,
         type: 'style',
         domHook,
+        slot: undefined,
       },
     ]);
   });
@@ -87,6 +103,7 @@
         pluginUrl: pluginBar._url,
         type: 'style',
         domHook,
+        slot: undefined,
       },
     ]);
   });
@@ -101,6 +118,7 @@
             pluginUrl: pluginFoo._url,
             type: 'decorate',
             domHook,
+            slot: undefined,
           },
         ]);
   });
@@ -119,13 +137,20 @@
     const newModuleStub = sandbox.stub();
     instance.onNewEndpoint('a-place', newModuleStub);
     instance.registerModule(
-        pluginFoo, 'a-place', 'replace', 'zaz-module', domHook);
+        pluginFoo,
+        {
+          endpoint: 'a-place',
+          type: 'replace',
+          moduleName: 'zaz-module',
+          domHook,
+        });
     assert.deepEqual(newModuleStub.lastCall.args[0], {
       moduleName: 'zaz-module',
       plugin: pluginFoo,
       pluginUrl: pluginFoo._url,
       type: 'replace',
       domHook,
+      slot: undefined,
     });
   });
 
@@ -139,6 +164,7 @@
         pluginUrl: pluginFoo._url,
         type: 'decorate',
         domHook,
+        slot: undefined,
       },
       {
         moduleName: 'bar-module',
@@ -146,6 +172,7 @@
         pluginUrl: pluginBar._url,
         type: 'style',
         domHook,
+        slot: undefined,
       },
     ]);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index d111d5c..9d79462 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -94,9 +94,9 @@
     return this._name;
   };
 
-  Plugin.prototype.registerStyleModule = function(endpointName, moduleName) {
+  Plugin.prototype.registerStyleModule = function(endpoint, moduleName) {
     pluginEndpoints.registerModule(
-        this, endpointName, EndpointType.STYLE, moduleName);
+        this, {endpoint, type: EndpointType.STYLE, moduleName});
   };
 
   /**
@@ -122,14 +122,15 @@
   };
 
   Plugin.prototype._registerCustomComponent = function(
-      endpointName, opt_moduleName, opt_options, dynamicEndpoint) {
+      endpoint, opt_moduleName, opt_options, dynamicEndpoint) {
     const type = opt_options && opt_options.replace ?
       EndpointType.REPLACE : EndpointType.DECORATE;
-    const hook = this._domHooks.getDomHook(endpointName, opt_moduleName);
-    const moduleName = opt_moduleName || hook.getModuleName();
+    const slot = opt_options && opt_options.slot || '';
+    const domHook = this._domHooks.getDomHook(endpoint, opt_moduleName);
+    const moduleName = opt_moduleName || domHook.getModuleName();
     pluginEndpoints.registerModule(
-        this, endpointName, type, moduleName, hook, dynamicEndpoint);
-    return hook.getPublicAPI();
+        this, {slot, endpoint, type, moduleName, domHook, dynamicEndpoint});
+    return domHook.getPublicAPI();
   };
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
index c003712..eb23708 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -168,6 +168,11 @@
    *    order to trigger computation when a label is removed from the change.
    */
   _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
+    if (labelInfo &&
+        !labelInfo.values && (labelInfo.rejected || labelInfo.approved)) {
+      return 'hidden';
+    }
+
     if (labelInfo && labelInfo.all) {
       for (const label of labelInfo.all) {
         if (label.value && label.value != labelInfo.default_value) {
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
index 94cfaea..b97b5b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
@@ -232,6 +232,18 @@
     element.labelInfo = {all: [{value: 1}]};
     assert.isTrue(isHidden(element.shadowRoot
         .querySelector('.placeholder')));
+    element.labelInfo = {rejected: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {approved: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
   });
 });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
index 7402e22..a4adbac 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
@@ -14,13 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-etag-decorator">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 // Limit cache size because /change/detail responses may be large.
 const MAX_CACHE_SIZE = 30;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 2d951d4..de4f12d 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -14,15 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-/* NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 */
 /* NB: Order is important, because of namespaced classes. */
 
 import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import 'es6-promise/lib/es6-promise.js';
-import 'whatwg-fetch/fetch.js';
 import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
 import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index c89378c..9b4bbaf 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -17,14 +17,7 @@
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-select">
-  <slot></slot>
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 
 /**
  * @extends PolymerElement
@@ -33,6 +26,12 @@
     LegacyElementMixin(PolymerElement)) {
   static get is() { return 'gr-select'; }
 
+  static get template() {
+    return html`
+      <slot></slot>
+    `;
+  }
+
   static get properties() {
     return {
       bindValue: {
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.js b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
index 8a7fb66..90110f0 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.js
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
@@ -24,7 +24,7 @@
   }
 
   /**
-   * @returns {string[]} array of all enabled experiments.
+   * @returns {!Array<string>} array of all enabled experiments.
    */
   get enabledExperiments() {
     return [];
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 4c259cd..1141d6b 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -261,14 +261,6 @@
     }
   },
   {
-    name: "es6-promise",
-    license: {
-      name: "es6-promise",
-      type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
-  },
-  {
     name: "isarray",
     license: SharedLicenses.IsArray
   },
@@ -291,14 +283,6 @@
   {
     name: "polymer-bridges",
     license: SharedLicenses.Polymer2018
-  },
-  {
-    name: "whatwg-fetch",
-    license: {
-      name: "whatwg-fetch",
-      type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
   }
 ];
 
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index a9668c3..32560ff 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -25,12 +25,10 @@
     "@polymer/polymer": "^3.3.0",
     "@webcomponents/shadycss": "^1.9.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "es6-promise": "^3.3.1",
     "page": "^1.11.5",
     "polymer-bridges": "file:../../polymer-bridges/",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
-    "polymer-resin": "^2.0.1",
-    "whatwg-fetch": "^3.0.0"
+    "polymer-resin": "^2.0.1"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
new file mode 100644
index 0000000..76b2787
--- /dev/null
+++ b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * This plugin will a button to quickly add favorite reviewers to
+ * reviewers in reply dialog.
+ */
+
+const onToggleButtonClicks = [];
+function toggleButtonClicked(expanded) {
+  onToggleButtonClicks.forEach(cb => {
+    cb(expanded);
+  });
+}
+
+class ReviewerShortcut extends Polymer.Element {
+  static get is() { return 'reviewer-shortcut'; }
+
+  static get properties() {
+    return {
+      change: Object,
+      expanded: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <button on-click="toggleControlContent">
+        [[computeButtonText(expanded)]]
+      </button>
+    `;
+  }
+
+  toggleControlContent() {
+    this.expanded = !this.expanded;
+    toggleButtonClicked(this.expanded);
+  }
+
+  computeButtonText(expanded) {
+    return expanded ? 'Collapse' : 'Add favorite reviewers';
+  }
+}
+
+customElements.define(ReviewerShortcut.is, ReviewerShortcut);
+
+class ReviewerShortcutContent extends Polymer.Element {
+  static get is() { return 'reviewer-shortcut-content'; }
+
+  static get properties() {
+    return {
+      change: Object,
+      hidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host([hidden]) {
+        display: none;
+      }
+      :host {
+        display: block;
+      }
+      </style>
+      <ul>
+        <li><button on-click="addApple">Apple</button></li>
+        <li><button on-click="addBanana">Banana</button></li>
+        <li><button on-click="addCherry">Cherry</button></li>
+      </ul>
+    `;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    onToggleButtonClicks.push(expanded => {
+      this.hidden = !expanded;
+    });
+  }
+
+  addApple() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Apple',
+        email: 'apple@gmail.com',
+        name: 'Apple',
+        _account_id: 0,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+
+  addBanana() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Banana',
+        email: 'banana@gmail.com',
+        name: 'B',
+        _account_id: 1,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+
+  addCherry() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Cherry',
+        email: 'cherry@gmail.com',
+        name: 'C',
+        _account_id: 2,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+}
+
+customElements.define(ReviewerShortcutContent.is, ReviewerShortcutContent);
+
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent(
+      'reply-reviewers', ReviewerShortcut.is, {slot: 'right'});
+  plugin.registerCustomComponent(
+      'reply-reviewers', ReviewerShortcutContent.is, {slot: 'below'});
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
index 8f08e27..2c89064 100644
--- a/polygerrit-ui/app/samples/bind-parameters.js
+++ b/polygerrit-ui/app/samples/bind-parameters.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class MyBindSample extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class MyBindSample extends PolymerElement {
   static get is() { return 'my-bind-sample'; }
 
   static get properties() {
@@ -62,4 +68,4 @@
   // between the file list and the change log
   plugin.registerCustomComponent(
       'change-view-integration', 'my-bind-sample');
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/extra-column-on-file-list.js b/polygerrit-ui/app/samples/extra-column-on-file-list.js
new file mode 100644
index 0000000..2e37c01
--- /dev/null
+++ b/polygerrit-ui/app/samples/extra-column-on-file-list.js
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * This plugin will an extra column to file list on change page to show
+ * the first character of the path.
+ */
+
+// Header of this extra column
+class ColumnHeader extends Polymer.Element {
+  static get is() { return 'column-header'; }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display: block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>First Char</div>
+    `;
+  }
+}
+
+customElements.define(ColumnHeader.is, ColumnHeader);
+
+// Content of this extra column
+class ColumnContent extends Polymer.Element {
+  static get is() { return 'column-content'; }
+
+  static get properties() {
+    return {
+      path: String,
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display:block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>[[getStatus(path)]]</div>
+    `;
+  }
+
+  getStatus(path) {
+    return path.charAt(0);
+  }
+}
+
+customElements.define(ColumnContent.is, ColumnContent);
+
+Gerrit.install(plugin => {
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-header-prepend', ColumnHeader.is);
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-content-prepend', ColumnContent.is);
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
index 00f95f5..5aaea30 100644
--- a/polygerrit-ui/app/samples/repo-command.js
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class RepoCommandLow extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class RepoCommandLow extends PolymerElement {
   static get is() { return 'repo-command-low'; }
 
   static get properties() {
@@ -27,11 +33,19 @@
 
   static get template() {
     return html`
-    <gr-repo-command
-      title="Low-level bork"
-      on-command-tap="_handleCommandTap">
-    </gr-repo-command>
-    `;
+    <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+    </style>
+    <h3>Low-level bork</h3>
+    <gr-button
+      on-click="_handleCommandTap"
+    >
+      Low-level bork
+    </gr-button>
+   `;
   }
 
   connectedCallback() {
@@ -70,4 +84,4 @@
   // Low-level API
   plugin.registerCustomComponent(
       'repo-command', 'repo-command-low');
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/some-screen.js b/polygerrit-ui/app/samples/some-screen.js
index 09acc81..c600fe4 100644
--- a/polygerrit-ui/app/samples/some-screen.js
+++ b/polygerrit-ui/app/samples/some-screen.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class SomeScreenMain extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class SomeScreenMain extends PolymerElement {
   static get is() { return 'some-screen-main'; }
 
   static get properties() {
@@ -64,4 +70,4 @@
   plugin.hook('change-metadata-item').onAttached(el => {
     el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/theme-plugin.js b/polygerrit-ui/app/samples/theme-plugin.js
index f3a8931..b3d4033 100644
--- a/polygerrit-ui/app/samples/theme-plugin.js
+++ b/polygerrit-ui/app/samples/theme-plugin.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 const customTheme = document.createElement('dom-module');
-customTheme.id = 'theme-plugin';
 customTheme.innerHTML = `
   <template>
     <style>
@@ -25,9 +24,9 @@
     </style>
   </template>
 `;
+customTheme.register('theme-plugin');
 
 const darkCustomTheme = document.createElement('dom-module');
-darkCustomTheme.id = 'dark-theme-plugin';
 darkCustomTheme.innerHTML = `
   <template>
     <style>
@@ -37,6 +36,7 @@
     </style>
   </template>
 `;
+darkCustomTheme.register('dark-theme-plugin');
 
 /**
  * This plugin will change the primary text color to red.
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 0103bf2..e4be858 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -15,17 +15,6 @@
  * limitations under the License.
  */
 
-function getPathFromNode(el) {
-  if (!el.tagName || el.tagName === 'GR-APP'
-      || el instanceof DocumentFragment
-      || el instanceof HTMLSlotElement) {
-    return '';
-  }
-  let path = el.tagName.toLowerCase();
-  if (el.id) path += `#${el.id}`;
-  if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
-  return path;
-}
 // TODO (dmfilippov): Each function must be exported separately. According to
 // the code style guide, a namespacing is not allowed.
 export const util = {
@@ -76,133 +65,4 @@
     };
     return wrappedPromise;
   },
-
-  /**
-   * Get computed style value.
-   *
-   * If ShadyCSS is provided, use ShadyCSS api.
-   * If `getComputedStyleValue` is provided on the element, use it.
-   * Otherwise fallback to native method (in polymer 2).
-   *
-   */
-  getComputedStyleValue: (name, el) => {
-    let style;
-    if (window.ShadyCSS) {
-      style = ShadyCSS.getComputedStyleValue(el, name);
-    } else if (el.getComputedStyleValue) {
-      style = el.getComputedStyleValue(name);
-    } else {
-      style = getComputedStyle(el).getPropertyValue(name);
-    }
-    return style;
-  },
-
-  /**
-   * Query selector on a dom element.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   */
-  querySelector: (el, selector) => {
-    let nodes = [el];
-    let result = null;
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      // Skip if it's an invalid node.
-      if (!node || !node.querySelector) continue;
-
-      // Try find it with native querySelector directly
-      result = node.querySelector(selector);
-
-      if (result) {
-        break;
-      }
-
-      // Add all nodes with shadowRoot and loop through
-      const allShadowNodes = [...node.querySelectorAll('*')]
-          .filter(child => !!child.shadowRoot)
-          .map(child => child.shadowRoot);
-      nodes = nodes.concat(allShadowNodes);
-
-      // Add shadowRoot of current node if has one
-      // as its not included in node.querySelectorAll('*')
-      if (node.shadowRoot) {
-        nodes.push(node.shadowRoot);
-      }
-    }
-    return result;
-  },
-
-  /**
-   * Query selector all dom elements matching with certain selector.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   * Note: this can be very expensive, only use when have to.
-   */
-  querySelectorAll: (el, selector) => {
-    let nodes = [el];
-    const results = new Set();
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      if (!node || !node.querySelectorAll) continue;
-
-      // Try find all from regular children
-      [...node.querySelectorAll(selector)]
-          .forEach(el => results.add(el));
-
-      // Add all nodes with shadowRoot and loop through
-      const allShadowNodes = [...node.querySelectorAll('*')]
-          .filter(child => !!child.shadowRoot)
-          .map(child => child.shadowRoot);
-      nodes = nodes.concat(allShadowNodes);
-
-      // Add shadowRoot of current node if has one
-      // as its not included in node.querySelectorAll('*')
-      if (node.shadowRoot) {
-        nodes.push(node.shadowRoot);
-      }
-    }
-    return [...results];
-  },
-
-  /**
-   * Retrieves the dom path of the current event.
-   *
-   * If the event object contains a `path` property, then use it,
-   * otherwise, construct the dom path based on the event target.
-   *
-   * @param {!Event} e
-   * @return {string}
-   * @example
-   *
-   * domNode.onclick = e => {
-   *  getEventPath(e); // eg: div.class1>p#pid.class2
-   * }
-   */
-  getEventPath: e => {
-    if (!e) return '';
-
-    let path = e.path;
-    if (!path || !path.length) {
-      path = [];
-      let el = e.target;
-      while (el) {
-        path.push(el);
-        el = el.parentNode || el.host;
-      }
-    }
-
-    return path.reduce((domPath, curEl) => {
-      const pathForEl = getPathFromNode(curEl);
-      if (!pathForEl) return domPath;
-      return domPath ? `${pathForEl}>${domPath}` : pathForEl;
-    }, '');
-  },
 };
diff --git a/polygerrit-ui/app/scripts/util_test.html b/polygerrit-ui/app/scripts/util_test.html
deleted file mode 100644
index a3893d2..0000000
--- a/polygerrit-ui/app/scripts/util_test.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div id="test" class="a b c">
-      <a class="testBtn"></a>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import {util} from './util.js';
-  suite('util tests', () => {
-    suite('getEventPath', () => {
-      test('empty event', () => {
-        assert.equal(util.getEventPath(), '');
-        assert.equal(util.getEventPath(null), '');
-        assert.equal(util.getEventPath(undefined), '');
-        assert.equal(util.getEventPath({}), '');
-      });
-
-      test('event with fake path', () => {
-        assert.equal(util.getEventPath({path: []}), '');
-        assert.equal(util.getEventPath({path: [
-          {tagName: 'dd'},
-        ]}), 'dd');
-      });
-
-      test('event with fake complicated path', () => {
-        assert.equal(util.getEventPath({path: [
-          {tagName: 'dd', id: 'test', className: 'a b'},
-          {tagName: 'DIV', id: 'test2', className: 'a b c'},
-        ]}), 'div#test2.a.b.c>dd#test.a.b');
-      });
-
-      test('event with fake target', () => {
-        const fakeTargetParent2 = {
-          tagName: 'DIV', id: 'test2', className: 'a b c',
-        };
-        const fakeTargetParent1 = {
-          parentNode: fakeTargetParent2,
-          tagName: 'dd',
-          id: 'test',
-          className: 'a b',
-        };
-        const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
-        assert.equal(
-            util.getEventPath({target: fakeTarget}),
-            'div#test2.a.b.c>dd#test.a.b>span'
-        );
-      });
-
-      test('event with real click', () => {
-        const element = fixture('basic');
-        const aLink = element.querySelector('a');
-        let path;
-        aLink.onclick = e => path = util.getEventPath(e);
-        MockInteractions.click(aLink);
-        assert.equal(
-            path,
-            'html>body>test-fixture#basic>div#test.a.b.c>a.testBtn'
-        );
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.js b/polygerrit-ui/app/styles/gr-voting-styles.js
index 4860428..60bf623 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.js
+++ b/polygerrit-ui/app/styles/gr-voting-styles.js
@@ -26,6 +26,7 @@
           box-shadow: none;
           box-sizing: border-box;
           min-width: 3em;
+          color: var(--vote-text-color);
         }
       }
     </style>
diff --git a/polygerrit-ui/app/styles/shared-styles.js b/polygerrit-ui/app/styles/shared-styles.js
index f5e048f..3e81761 100644
--- a/polygerrit-ui/app/styles/shared-styles.js
+++ b/polygerrit-ui/app/styles/shared-styles.js
@@ -103,19 +103,19 @@
         font-weight: var(--font-weight-normal);
         line-height: var(--line-height-small);
       }
-      h1, .font-h1 {
+      .heading-1 {
         font-family: var(--header-font-family);
         font-size: var(--font-size-h1);
         font-weight: var(--font-weight-h1);
         line-height: var(--line-height-h1);
       }
-      h2, .font-h2 {
+      .heading-2 {
         font-family: var(--header-font-family);
         font-size: var(--font-size-h2);
         font-weight: var(--font-weight-h2);
         line-height: var(--line-height-h2);
       }
-      h3, .font-h3 {
+      .heading-3 {
         font-family: var(--header-font-family);
         font-size: var(--font-size-h3);
         font-weight: var(--font-weight-h3);
diff --git a/polygerrit-ui/app/styles/themes/app-theme.js b/polygerrit-ui/app/styles/themes/app-theme.js
index 5d5d9e3..718d6a5 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.js
+++ b/polygerrit-ui/app/styles/themes/app-theme.js
@@ -38,10 +38,12 @@
   --primary-button-text-color: white;
     /* Used on text color for change list that doesn't need user's attention. */
   --reviewed-text-color: black;
+  --vote-text-color: black;
+  --status-text-color: white;
   --tooltip-text-color: white;
-  --vote-text-color-recommended: #388e3c;
-  --vote-text-color-disliked: #d32f2f;
-
+  --negative-red-text-color: #d93025;
+  --positive-green-text-color: #188038;
+  
   /* background colors */
   /* primary background colors */
   --background-color-primary: #ffffff;
@@ -83,6 +85,16 @@
   --border-color: #e8e8e8;
   --comment-separator-color: #dadce0;
 
+  /* status colors */
+  --status-merged: #188038;
+  --status-abandoned: #5f6368;
+  --status-wip: #795548;
+  --status-private: #a142f4;
+  --status-conflict: #d93025;
+  --status-active: #1976d2;
+  --status-ready: #b80672;
+  --status-custom: #681da8;
+
   /* fonts */
   --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
index 4248878..18b2fd6 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ b/polygerrit-ui/app/styles/themes/dark-theme.html
@@ -33,12 +33,14 @@
       --deemphasized-text-color: #9aa0a6;
       --default-button-text-color: #8ab4f8;
       --error-text-color: red;
-      --primary-button-text-color: var(--primary-text-color);
+      --primary-button-text-color: black;
         /* Used on text color for change list doesn't need user's attention. */
       --reviewed-text-color: #dadce0;
+      --vote-text-color: black;
+      --status-text-color: black;
       --tooltip-text-color: white;
-      --vote-text-color-recommended: #388e3c;
-      --vote-text-color-disliked: #d32f2f;
+      --negative-red-text-color: #f28b82;
+      --positive-green-text-color: #81c995;
 
       /* background colors */
       /* primary background colors */
@@ -71,6 +73,16 @@
       --border-color: #5f6368;
       --comment-separator-color: var(--border-color);
 
+      /* status colors */
+      --status-merged: #5bb974;
+      --status-abandoned: #dadce0;
+      --status-wip: #bcaaa4;
+      --status-private: #d7aefb;
+      --status-conflict: #f28b82;
+      --status-active: #669df6;
+      --status-ready: #f439a0;
+      --status-custom: #af5cf7;
+
       /* fonts */
       --font-weight-bold: 700; /* 700 is the same as 'bold' */
 
diff --git a/polygerrit-ui/app/test/tests.js b/polygerrit-ui/app/test/tests.js
index 6892287..f095302 100644
--- a/polygerrit-ui/app/test/tests.js
+++ b/polygerrit-ui/app/test/tests.js
@@ -41,7 +41,6 @@
   'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
   'admin/gr-plugin-list/gr-plugin-list_test.html',
   'admin/gr-repo-access/gr-repo-access_test.html',
-  'admin/gr-repo-command/gr-repo-command_test.html',
   'admin/gr-repo-commands/gr-repo-commands_test.html',
   'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
   'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
@@ -154,7 +153,6 @@
   'shared/gr-account-link/gr-account-link_test.html',
   'shared/gr-alert/gr-alert_test.html',
   'shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html',
-  'shared/gr-autocomplete/gr-autocomplete_test.html',
   'shared/gr-avatar/gr-avatar_test.html',
   'shared/gr-button/gr-button_test.html',
   'shared/gr-change-star/gr-change-star_test.html',
@@ -246,7 +244,6 @@
   'gr-group-suggestions-provider/gr-group-suggestions-provider_test.html',
   'gr-display-name-utils/gr-display-name-utils_test.html',
   'gr-email-suggestions-provider/gr-email-suggestions-provider_test.html',
-  'util_test.html',
 ];
 /* eslint-enable max-len */
 for (let file of scripts) {
diff --git a/polygerrit-ui/app/utils/dom-util.js b/polygerrit-ui/app/utils/dom-util.js
new file mode 100644
index 0000000..a9f080f
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util.js
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function getPathFromNode(el) {
+  if (!el.tagName || el.tagName === 'GR-APP'
+        || el instanceof DocumentFragment
+        || el instanceof HTMLSlotElement) {
+    return '';
+  }
+  let path = el.tagName.toLowerCase();
+  if (el.id) path += `#${el.id}`;
+  if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
+  return path;
+}
+
+/**
+ * Get computed style value.
+ *
+ * If ShadyCSS is provided, use ShadyCSS api.
+ * If `getComputedStyleValue` is provided on the element, use it.
+ * Otherwise fallback to native method (in polymer 2).
+ *
+ */
+export function getComputedStyleValue(name, el) {
+  let style;
+  if (window.ShadyCSS) {
+    style = ShadyCSS.getComputedStyleValue(el, name);
+  } else if (el.getComputedStyleValue) {
+    style = el.getComputedStyleValue(name);
+  } else {
+    style = getComputedStyle(el).getPropertyValue(name);
+  }
+  return style;
+}
+
+/**
+ * Query selector on a dom element.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ */
+export function querySelector(el, selector) {
+  let nodes = [el];
+  let result = null;
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    // Skip if it's an invalid node.
+    if (!node || !node.querySelector) continue;
+
+    // Try find it with native querySelector directly
+    result = node.querySelector(selector);
+
+    if (result) {
+      break;
+    }
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+        .filter(child => !!child.shadowRoot)
+        .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (node.shadowRoot) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return result;
+}
+
+/**
+ * Query selector all dom elements matching with certain selector.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ * Note: this can be very expensive, only use when have to.
+ */
+export function querySelectorAll(el, selector) {
+  let nodes = [el];
+  const results = new Set();
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    if (!node || !node.querySelectorAll) continue;
+
+    // Try find all from regular children
+    [...node.querySelectorAll(selector)]
+        .forEach(el => results.add(el));
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+        .filter(child => !!child.shadowRoot)
+        .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (node.shadowRoot) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return [...results];
+}
+
+/**
+ * Retrieves the dom path of the current event.
+ *
+ * If the event object contains a `path` property, then use it,
+ * otherwise, construct the dom path based on the event target.
+ *
+ * @param {!Event} e
+ * @return {string}
+ * @example
+ *
+ * domNode.onclick = e => {
+ *  getEventPath(e); // eg: div.class1>p#pid.class2
+ * }
+ */
+export function getEventPath(e) {
+  if (!e) return '';
+
+  let path = e.path;
+  if (!path || !path.length) {
+    path = [];
+    let el = e.target;
+    while (el) {
+      path.push(el);
+      el = el.parentNode || el.host;
+    }
+  }
+
+  return path.reduce((domPath, curEl) => {
+    const pathForEl = getPathFromNode(curEl);
+    if (!pathForEl) return domPath;
+    return domPath ? `${pathForEl}>${domPath}` : pathForEl;
+  }, '');
+}
+
+/**
+ * Are any ancestors of the element (or the element itself) members of the
+ * given class.
+ *
+ * @param {!Element} element
+ * @param {string} className
+ * @param {Element=} opt_stopElement If provided, stop traversing the
+ *     ancestry when the stop element is reached. The stop element's class
+ *     is not checked.
+ * @return {boolean}
+ */
+export function descendedFromClass(element, className, opt_stopElement) {
+  let isDescendant = element.classList.contains(className);
+  while (!isDescendant && element.parentElement &&
+        (!opt_stopElement || element.parentElement !== opt_stopElement)) {
+    isDescendant = element.classList.contains(className);
+    element = element.parentElement;
+  }
+  return isDescendant;
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
new file mode 100644
index 0000000..c317578
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util_test.js
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../test/common-test-setup-karma.js';
+import {getComputedStyleValue, querySelector, querySelectorAll, descendedFromClass, getEventPath} from './dom-util.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+class TestEle extends PolymerElement {
+  static get is() {
+    return 'dom-util-test-element';
+  }
+
+  static get template() {
+    return html`
+    <div>
+      <div class="a">
+        <div class="b">
+          <div class="c"></div>
+        </div>
+        <span class="ss"></span>
+      </div>
+      <span class="ss"></span>
+    </div>
+    `;
+  }
+}
+
+customElements.define(TestEle.is, TestEle);
+
+const basicFixture = fixtureFromTemplate(html`
+  <div id="test" class="a b c">
+    <a class="testBtn" style="color:red;"></a>
+    <dom-util-test-element></dom-util-test-element>
+    <span class="ss"></span>
+  </div>
+`);
+
+suite('dom-util tests', () => {
+  suite('getEventPath', () => {
+    test('empty event', () => {
+      assert.equal(getEventPath(), '');
+      assert.equal(getEventPath(null), '');
+      assert.equal(getEventPath(undefined), '');
+      assert.equal(getEventPath({}), '');
+    });
+
+    test('event with fake path', () => {
+      assert.equal(getEventPath({path: []}), '');
+      assert.equal(getEventPath({path: [
+        {tagName: 'dd'},
+      ]}), 'dd');
+    });
+
+    test('event with fake complicated path', () => {
+      assert.equal(getEventPath({path: [
+        {tagName: 'dd', id: 'test', className: 'a b'},
+        {tagName: 'DIV', id: 'test2', className: 'a b c'},
+      ]}), 'div#test2.a.b.c>dd#test.a.b');
+    });
+
+    test('event with fake target', () => {
+      const fakeTargetParent2 = {
+        tagName: 'DIV', id: 'test2', className: 'a b c',
+      };
+      const fakeTargetParent1 = {
+        parentNode: fakeTargetParent2,
+        tagName: 'dd',
+        id: 'test',
+        className: 'a b',
+      };
+      const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
+      assert.equal(
+          getEventPath({target: fakeTarget}),
+          'div#test2.a.b.c>dd#test.a.b>span'
+      );
+    });
+
+    test('event with real click', () => {
+      const element = basicFixture.instantiate();
+      const aLink = element.querySelector('a');
+      let path;
+      aLink.onclick = e => path = getEventPath(e);
+      MockInteractions.click(aLink);
+      assert.equal(
+          path,
+          `html>body>test-fixture#${basicFixture.fixtureId}>` +
+          'div#test.a.b.c>a.testBtn'
+      );
+    });
+  });
+
+  suite('querySelector and querySelectorAll', () => {
+    test('query cross shadow dom', () => {
+      const element = basicFixture.instantiate();
+      const theFirstEl = querySelector(element, '.ss');
+      const allEls = querySelectorAll(element, '.ss');
+      assert.equal(allEls.length, 3);
+      assert.equal(theFirstEl, allEls[0]);
+    });
+  });
+
+  suite('getComputedStyleValue', () => {
+    test('color style', () => {
+      const element = basicFixture.instantiate();
+      const testBtn = querySelector(element, '.testBtn');
+      assert.equal(
+          getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)'
+      );
+    });
+  });
+
+  suite('descendedFromClass', () => {
+    test('basic tests', () => {
+      const element = basicFixture.instantiate();
+      const testEl = querySelector(element, 'dom-util-test-element');
+      // .c is a child of .a and not vice versa.
+      assert.isTrue(descendedFromClass(querySelector(testEl, '.c'), 'a'));
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.a'), 'c'));
+
+      // Stops at stop element.
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.c'), 'a',
+          querySelector(testEl, '.b')));
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 821b724..8fb3eea 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -328,11 +328,6 @@
 "ba-linkify@file:../../lib/ba-linkify/src":
   version "1.0.0"
 
-es6-promise@^3.3.1:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
-  integrity sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=
-
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
@@ -362,8 +357,3 @@
   dependencies:
     "@polymer/polymer" "^3.0.2"
     "@webcomponents/webcomponentsjs" "^2.0.3"
-
-whatwg-fetch@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
-  integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 120aff5..c44493d 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -61,6 +61,7 @@
 
 	dirListingMux := http.NewServeMux()
 	dirListingMux.Handle("/styles/", http.StripPrefix("/styles/", http.FileServer(http.Dir("app/styles"))))
+	dirListingMux.Handle("/samples/", http.StripPrefix("/samples/", http.FileServer(http.Dir("app/samples"))))
 	dirListingMux.Handle("/elements/", http.StripPrefix("/elements/", http.FileServer(http.Dir("app/elements"))))
 	dirListingMux.Handle("/behaviors/", http.StripPrefix("/behaviors/", http.FileServer(http.Dir("app/behaviors"))))
 
diff --git a/resources/com/google/gerrit/httpd/raw/HostPage.html b/resources/com/google/gerrit/httpd/raw/HostPage.html
deleted file mode 100644
index c0d8446..0000000
--- a/resources/com/google/gerrit/httpd/raw/HostPage.html
+++ /dev/null
@@ -1,26 +0,0 @@
-<html>
-  <head>
-    <title>Gerrit Code Review</title>
-    <meta name="gwt:property" content="locale=en_US" />
-    <script id="gerrit_hostpagedata"></script>
-    <style id="gerrit_sitecss" type="text/css"></style>
-    <link rel="shortcut icon" type="image/x-icon" href="favicon.ico" />
-  </head>
-  <body>
-    <div id="gerrit_topmenu"></div>
-    <div id="gerrit_header"></div>
-    <div id="gerrit_startinggerrit" style="margin-left: 10px;">
-      <p>Loading <a href="https://www.gerritcodereview.com/" target="_blank">Gerrit Code Review</a> ...</p>
-      <noscript>
-        <p>Gerrit requires a JavaScript enabled browser.</p>
-      </noscript>
-    </div>
-    <div id="gerrit_body"></div>
-    <div style="clear: both">
-      <div id="gerrit_footer"></div>
-      <div id="gerrit_btmmenu"></div>
-    </div>
-    <iframe src="javascript:''" id="__gwt_historyFrame" tabIndex='-1' style="position:absolute;width:0;height:0;border:0"></iframe>
-    <script id="gerrit_module"></script>
-  </body>
-</html>
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
index a0f5bd1..7febbac 100644
--- a/tools/bzl/BUILD
+++ b/tools/bzl/BUILD
@@ -3,3 +3,8 @@
     "test_empty.sh",
     "test_license.sh",
 ])
+
+sh_test(
+    name = "always_pass_test",
+    srcs = ["always_pass_test.sh"],
+)
diff --git a/tools/bzl/always_pass_test.sh b/tools/bzl/always_pass_test.sh
new file mode 100755
index 0000000..15c58ca
--- /dev/null
+++ b/tools/bzl/always_pass_test.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+#
+# Copyright (C) 2020 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This is a dummy test to put on the command line to avoid no tests
+# found outcome in `bazel test` command. See this upstream issue:
+# https://github.com/bazelbuild/bazel/issues/11465
+
+exit 0
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index c9ac0fe..2b473bc 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -23,6 +23,7 @@
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
     "//lib/log:impl-log4j",
+    "//lib:jgit-ssh-jsch",
     "//prolog:gerrit-prolog-common",
     "//resources:log4j-config",
 ]
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 3cffb79..d49e700 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -1,5 +1,6 @@
 load("@rules_java//java:defs.bzl", "java_binary", "java_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//:version.bzl", "GERRIT_VERSION")
 
 PLUGIN_DEPS = ["//plugins:plugin-lib"]
 
@@ -39,7 +40,10 @@
 
     java_binary(
         name = "%s__non_stamped" % name,
-        deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
+        deploy_manifest_lines = manifest_entries + [
+            "Gerrit-ApiType: plugin",
+            "Gerrit-ApiVersion: " + GERRIT_VERSION,
+        ],
         main_class = "Dummy",
         runtime_deps = [
             ":%s__plugin" % name,
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 9915a6e..f360fa5 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -181,6 +181,8 @@
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/resources')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.junit/src')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/src')
+        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/resources')
 
     def classpathentry(kind, path, src=None, out=None, exported=None, excluding=None):
         e = doc.createElement('classpathentry')
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 0f6f6d4..8748250 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -44,9 +44,6 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index cb0f882..ae31ac9 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -44,9 +44,6 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index e542b47..dc25c80 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -44,9 +44,6 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 4a69f2e..d21f88c 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -44,9 +44,6 @@
       <name>Han-Wen Nienhuys</name>
     </developer>
     <developer>
-      <name>Hugo Arès</name>
-    </developer>
-    <developer>
       <name>Luca Milanesio</name>
     </developer>
     <developer>
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 2af06b4..6fafe63 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^0.41.0",
-    "@bazel/typescript": "^1.0.1",
+    "@bazel/rollup": "^1.6.1",
+    "@bazel/typescript": "^1.6.1",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -16,7 +16,7 @@
     "rollup": "^1.27.5",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "^3.7.4"
+    "typescript": "3.8.2"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 0648c8d..78349fa 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^0.41.0":
-  version "0.41.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-0.41.0.tgz#8dfaccc239f3efbae1c816b0ce2aeb6069d23582"
-  integrity sha512-M+ybGfcxTXnAS1QiaijLEfUznNYLA0cqeGXnYHSRrOhq2U7yesfavxbBtfLSKtg32ktmlHts5te8Zg82BS4DPQ==
+"@bazel/rollup@^1.6.1":
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-1.6.1.tgz#7ec9d39a3fca23256fca55410339724804802616"
+  integrity sha512-FhblJkpd8VKl9txhAAIotSsIOHRpPd2FgJG7Op3uV7LfaCVBmUs3XDBZCgfwt5wmEpd3lwCHA1Ei+O/URS2+5w==
 
-"@bazel/typescript@^1.0.1":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.1.0.tgz#b57ac6c6d627577f394a60fb540fbbdf53bcff0d"
-  integrity sha512-QnTdb6rwZUR+KfUuAdyazpkA7BOvrWRe7tkPDdyIZHJdBPYdpJW+AapnFSfxvXEIP0Nwesl5KP6Saau0GPiBLg==
+"@bazel/typescript@^1.6.1":
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.6.1.tgz#1bf83c20021d359bc9b532181981ac540584a30c"
+  integrity sha512-wQ9AASRcG1jLQOpJfNOMjZzPpwIV/9qTOxCFvp55ga6A5a2qveQr8JJ7jHHbBM0LtK+slEPixXmVmtEOwfKsIg==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -949,11 +949,16 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.118.tgz#8014a9b1dee0b72b4d7cd142563f1af21241c3a2"
   integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
 
-"@types/node@^10.1.0", "@types/node@^10.17.12":
+"@types/node@^10.1.0":
   version "10.17.13"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
   integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
 
+"@types/node@^10.17.12":
+  version "10.17.24"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
+  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
+
 "@types/node@^4.0.30":
   version "4.9.4"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.4.tgz#75ef91733afaa856b01e12da6ecf48aa9d5e221f"
@@ -7871,10 +7876,10 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@^3.7.4:
-  version "3.7.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
-  integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
+typescript@3.8.2:
+  version "3.8.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.2.tgz#91d6868aaead7da74f493c553aeff76c0c0b1d5a"
+  integrity sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index eced448..1da5004 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -23,8 +23,8 @@
 
     maven_jar(
         name = "dropwizard-core",
-        artifact = "io.dropwizard.metrics:metrics-core:4.1.8",
-        sha1 = "f4765fc53af5d5712261a7e29afe97beb6b30118",
+        artifact = "io.dropwizard.metrics:metrics-core:4.1.9",
+        sha1 = "dd76a62b007ffea9e6aba10f64c04173ef65f895",
     )
 
     SSHD_VERS = "2.4.0"
@@ -96,8 +96,8 @@
     # and httpasyncclient as necessary.
     maven_jar(
         name = "elasticsearch-rest-client",
-        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.7.0",
-        sha1 = "5fc25eec3940bc0e9b0ffddcf50554a609e9db8e",
+        artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.7.1",
+        sha1 = "6d44a8e35c11df6883747200bcf46f476a1782b8",
     )
 
     maven_jar(
@@ -106,6 +106,29 @@
         sha1 = "f84302e14648f9f63c0c73951054aeb2ff0b810a",
     )
 
+    # Google internal dependencies: these are developed at Google, so there is
+    # no concern about version skew.
+
+    FLOGGER_VERS = "0.5.1"
+
+    maven_jar(
+        name = "flogger",
+        artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
+        sha1 = "71d1e2cef9cc604800825583df56b8ef5c053f14",
+    )
+
+    maven_jar(
+        name = "flogger-log4j-backend",
+        artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
+        sha1 = "5e2794b75c88223f263f1c1a9d7ea51e2dc45732",
+    )
+
+    maven_jar(
+        name = "flogger-system-backend",
+        artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
+        sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
+    )
+
     # Test-only dependencies below.
 
     maven_jar(
@@ -120,18 +143,18 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    TESTCONTAINERS_VERSION = "1.14.2"
+    TESTCONTAINERS_VERSION = "1.14.3"
 
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "d74bc045fb5f30988b0adff20244412972a9f564",
+        sha1 = "071fc82ba663f469447a19434e7db90f3a872753",
     )
 
     maven_jar(
         name = "testcontainers-elasticsearch",
         artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-        sha1 = "66e1a6da0362beee83673b877c9c2e0536d6912c",
+        sha1 = "3709e2ebb0b6aa4e2ba2b6ca92ffdd3bf637a86c",
     )
 
     maven_jar(
diff --git a/yarn.lock b/yarn.lock
index 8379dd7..02ac32e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,15 +485,15 @@
     lodash "^4.17.11"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^1.1.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-1.2.0.tgz#8b9569ed6f1c00d2a833567901f8ee4600a389fb"
-  integrity sha512-yrXW+AAUoqc9qN/CweD5p8OEN9bNKFjXnXPBRE4w84LxpkmaJFx+yQJ++c1F57zWMoq2o9EV4CM7y+mK8zxwUg==
+"@bazel/rollup@^1.6.1":
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-1.6.1.tgz#7ec9d39a3fca23256fca55410339724804802616"
+  integrity sha512-FhblJkpd8VKl9txhAAIotSsIOHRpPd2FgJG7Op3uV7LfaCVBmUs3XDBZCgfwt5wmEpd3lwCHA1Ei+O/URS2+5w==
 
-"@bazel/typescript@^1.0.1":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.2.0.tgz#ab2016e1d6eb7a86b44536e887f51eaf3d75f1a7"
-  integrity sha512-hPEG8K0psyEcs6HFRiqZNQwXL/dQ8sXKdrNFWv87+rh+YUNfd58uktoynhllympOPThcbUZcZicLWBEFQOc8nA==
+"@bazel/typescript@^1.6.1":
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.6.1.tgz#1bf83c20021d359bc9b532181981ac540584a30c"
+  integrity sha512-wQ9AASRcG1jLQOpJfNOMjZzPpwIV/9qTOxCFvp55ga6A5a2qveQr8JJ7jHHbBM0LtK+slEPixXmVmtEOwfKsIg==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -907,9 +907,9 @@
   integrity sha512-rp7La3m845mSESCgsJePNL/JQyhkOJA6G4vcwvVgkDAwHhGdq5GCumxmPjEk1MZf+8p5ZQAUE7tqgQRQTXN7uQ==
 
 "@types/node@^10.1.0":
-  version "10.17.13"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
-  integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
+  version "10.17.24"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
+  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
 
 "@types/node@^4.0.30":
   version "4.9.3"
@@ -8596,7 +8596,12 @@
     source-map "^0.5.6"
     source-map-support "^0.4.2"
 
-tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.8.1:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+
+tslib@^1.9.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
   integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
@@ -8657,16 +8662,16 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
+typescript@3.8.2:
+  version "3.8.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.2.tgz#91d6868aaead7da74f493c553aeff76c0c0b1d5a"
+  integrity sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==
+
 typescript@^2.4.1:
   version "2.9.2"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.2.tgz#1cbf61d05d6b96269244eb6a3bce4bd914e0f00c"
   integrity sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==
 
-typescript@^3.7.4:
-  version "3.7.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
-  integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
-
 typical@^2.6.1:
   version "2.6.1"
   resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d"