Merge changes I8c01f550,Ic53dfcf7,I20118b57

* changes:
  SubscriptionGraph: Make the factory an interface
  SubmoduleOp: Create CircularPathFinder class
  SubscriptionGraph: Container for subscription relation
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 1bf51a1..1f4fd9c 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2242,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.
@@ -2265,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::
 +
@@ -2946,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.
@@ -3763,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
@@ -5374,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/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 e70c475..91eef76 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -60,8 +60,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "d14076339deb08e5460c221fae5c5e9605d2ef4848eee1f0c81c9ffdc1ab31c1",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/1.6.1/rules_nodejs-1.6.1.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/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/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/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/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/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/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/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 3d43f59..5a9fb99 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -218,7 +218,7 @@
       ChangeMessage changeMessage,
       NotifyResolver.Result notify,
       RepoView repoView)
-      throws EmailException, IOException {
+      throws EmailException {
     Account.Id userId = user.get().getAccountId();
     if (userId.equals(reviewer.account().id())) {
       // The user knows they removed themselves, don't bother emailing them.
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/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 0a99d83..1a4a335 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -66,6 +66,9 @@
   /** The cause of an error. */
   public abstract Optional<String> cause();
 
+  /** The SHA1 of a commit. */
+  public abstract Optional<String> commit();
+
   /** The type of an event. */
   public abstract Optional<String> eventType();
 
@@ -282,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);
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index 5b3b34c..bca5338 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -50,7 +50,7 @@
     setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
     setMessageId(
         messageIdGenerator.fromReasonAccountIdAndTimestamp(
-            "HTTP password change", user.getAccountId(), TimeUtil.nowTs()));
+            "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
index 01e165d..3a411dc 100644
--- a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -111,7 +111,7 @@
    * @return MessageId that depends on the reason, accountId, and timestamp.
    */
   public MessageId fromReasonAccountIdAndTimestamp(
-      String reason, Account.Id accountId, Timestamp timestamp) {
+      String reason, Account.Id accountId, Instant timestamp) {
     return new AutoValue_MessageIdGenerator_MessageId(
         reason + "-" + accountId.toString() + "-" + timestamp.toString());
   }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index fdd6b81..8f63177 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -259,9 +259,12 @@
     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) {
-      va.headers.put(FieldName.MESSAGE_ID, new EmailHeader.String(messageId.id() + suffix));
+      String message = "<" + messageId.id() + suffix + "@" + getGerritHost() + ">";
+      message = message.replaceAll("\\s", "");
+      va.headers.put(FieldName.MESSAGE_ID, new EmailHeader.String(message));
     }
   }
 
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/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/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/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/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/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/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 34e402e..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 "";
   }
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/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
index 7cb5377..8e08b1c 100644
--- a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -26,6 +26,7 @@
 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;
 
@@ -43,23 +44,25 @@
     gApi.changes().id(result.getChangeId()).abandon();
     assertThat(getMessageId(sender))
         .isEqualTo(
-            repository
-                    .getRefDatabase()
-                    .exactRef(result.getChange().getId().toRefPrefix() + "meta")
-                    .getObjectId()
-                    .getName()
-                + "-HTML");
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
     sender.clear();
 
     gApi.changes().id(result.getChangeId()).restore();
     assertThat(getMessageId(sender))
         .isEqualTo(
-            repository
-                    .getRefDatabase()
-                    .exactRef(result.getChange().getId().toRefPrefix() + "meta")
-                    .getObjectId()
-                    .getName()
-                + "-HTML");
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
   }
 
   @Test
@@ -80,19 +83,22 @@
 
     assertThat(getMessageId(sender))
         .isEqualTo(
-            allUsersRepo
-                    .getRefDatabase()
-                    .exactRef(RefNames.refsUsers(admin.id()))
-                    .getObjectId()
-                    .getName()
-                + "-HTML");
+            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(getMessageId(sender)).contains("HTTP password change-" + admin.id().toString());
+    assertThat(newPassword).isNotNull();
+    assertThat(getMessageId(sender))
+        .containsMatch("<HTTP_password_change-" + admin.id().toString() + ".*@.*>");
   }
 
   @Test
@@ -123,4 +129,10 @@
             (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/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/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
index 3228900..a8fd834 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.util.time.TimeUtil;
-import java.sql.Timestamp;
 import java.time.Instant;
 import javax.inject.Inject;
 import org.eclipse.jgit.lib.Repository;
@@ -38,7 +37,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
       String sha1 =
-          repo.getRefDatabase().getRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
       assertThat(sha1).isEqualTo(messageId);
     }
   }
@@ -51,7 +50,7 @@
       String messageId = messageIdGenerator.fromChangeUpdate(project, patchsetId).id();
       String sha1 =
           repo.getRefDatabase()
-              .getRef(String.format("%smeta", patchsetId.changeId().toRefPrefix()))
+              .findRef(String.format("%smeta", patchsetId.changeId().toRefPrefix()))
               .getObjectId()
               .getName();
       assertThat(sha1).isEqualTo(messageId);
@@ -74,7 +73,7 @@
   @Test
   public void fromReasonAccountIdAndTimestamp() throws Exception {
     String reason = "reason";
-    Timestamp timestamp = TimeUtil.nowTs();
+    Instant timestamp = TimeUtil.now();
     assertThat(
             messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
         .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/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/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 0e23603..0514e03 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -149,11 +149,8 @@
 
   @Test
   public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
-    String refsHeads = "refs/heads/*";
     GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
     GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-    String groupId = "global:Registered-Users";
-    String otherGroupId = "global:Anonymous-Users";
 
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
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 4ae77da..d7d67b8 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -48,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;
@@ -300,7 +301,7 @@
 
     // ensure the message header contains a valid message id.
     assertThat(((EmailHeader.String) (message.headers().get("Message-ID"))).getString())
-        .isEqualTo("some id-REJECTION-HTML");
+        .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/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index fb3a0db..ddcfe0c 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -151,7 +151,7 @@
 
     // This is the loader that we configure for the cache when calling .loader(...)
     @SuppressWarnings("unchecked")
-    CacheLoader<String, String> baseLoader = (CacheLoader<String, String>) mock(CacheLoader.class);
+    CacheLoader<String, String> baseLoader = mock(CacheLoader.class);
     resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
 
     // We wrap baseLoader just like H2CacheFactory is wrapping it. The wrapped version will call out
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/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/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 02cced3..7671def 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 02cced37fd755a1123b1ec18af96503683d88f50
+Subproject commit 7671def07882aab89b19eb7496418588ea7375d9
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-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-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_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 f8580e0..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';
@@ -1019,10 +1019,7 @@
     this.$.fileList.collapseAllDiffs();
   }
 
-  _paramsChanged(value, oldValue) {
-    const paramsChanged = JSON.stringify(oldValue) !== JSON.stringify(value);
-    if (!paramsChanged) return;
-
+  _paramsChanged(value) {
     if (value.view !== GerritNav.View.CHANGE) {
       this._initialLoadComplete = false;
       return;
@@ -1138,6 +1135,14 @@
     this.viewState.numFilesShown = numFilesShown;
   }
 
+  _handleMessageAnchorTap(e) {
+    const hash = MSG_PREFIX + e.detail.id;
+    const url = GerritNav.getUrlForChange(this._change,
+        this._patchRange.patchNum, this._patchRange.basePatchNum,
+        this._editMode, hash);
+    history.replaceState(null, '', url);
+  }
+
   _maybeScrollToMessage(hash) {
     if (hash.startsWith(MSG_PREFIX)) {
       this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
@@ -1998,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
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 50b6d2d..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
@@ -711,6 +711,7 @@
             change-comments="[[_changeComments]]"
             project-name="[[_change.project]]"
             show-reply-buttons="[[_loggedIn]]"
+            on-message-anchor-tap="_handleMessageAnchorTap"
             on-reply="_handleMessageReply"
           ></gr-messages-list>
         </template>
@@ -725,6 +726,7 @@
             change-comments="[[_changeComments]]"
             project-name="[[_change.project]]"
             show-reply-buttons="[[_loggedIn]]"
+            on-message-anchor-tap="_handleMessageAnchorTap"
             on-reply="_handleMessageReply"
           ></gr-messages-list-experimental>
         </template>
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 192ba8f..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,21 @@
   });
 
   const getCustomCssValue =
-      cssParam => util.getComputedStyleValue(cssParam, element);
+      cssParam => getComputedStyleValue(cssParam, element);
+
+  test('_handleMessageAnchorTap', () => {
+    element._changeNum = '1';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForChange');
+    const replaceStateStub = sandbox.stub(history, 'replaceState');
+    element._handleMessageAnchorTap({detail: {id: 'a12345'}});
+
+    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.isTrue(replaceStateStub.called);
+  });
 
   suite('plugins adding to file tab', () => {
     setup(done => {
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/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index adcf2e2..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
  *
@@ -273,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',
@@ -676,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.
@@ -708,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.
    *
@@ -761,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;
@@ -1066,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);
     }
   }
@@ -1129,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;
@@ -1178,7 +1255,7 @@
     }
 
     this._updateDiffCursor();
-    this.$.diffCursor.handleDiffUpdate();
+    this.$.diffCursor.reInitAndUpdateStops();
   }
 
   _clearCollapsedDiffs(collapsedDiffs) {
@@ -1202,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},
@@ -1239,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();
     }));
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 d1238f3..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;
     }
@@ -164,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;
@@ -200,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);
@@ -233,6 +247,9 @@
     .editFileControls {
       width: 7em;
     }
+    .markReviewed:focus {
+      outline: none;
+    }
     .markReviewed,
     .pathLink {
       display: inline-block;
@@ -255,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;
     }
 
@@ -295,9 +310,17 @@
         display: none;
       }
     }
+    :host(.hideComments) {
+      --gr-comment-thread-display: none;
+    }
   </style>
-  <div id="container" on-click="_handleFileListClick">
-    <div class="header-row row">
+  <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
@@ -305,14 +328,20 @@
           items="[[_dynamicPrependedHeaderEndpoints]]"
           as="headerEndpoint"
         >
-          <gr-endpoint-decorator name$="[[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">File</div>
-      <div class="comments">Comments</div>
-      <div class="sizeBars">Size</div>
-      <div class="header-stats">Delta</div>
+      <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
@@ -320,14 +349,18 @@
           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
@@ -344,6 +377,7 @@
           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]]">
@@ -352,7 +386,9 @@
               items="[[_dynamicPrependedContentEndpoints]]"
               as="contentEndpoint"
             >
-              <gr-endpoint-decorator name="[[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]]">
@@ -366,6 +402,7 @@
           <span
             data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
             class="path"
+            role="gridcell"
           >
             <a
               class="pathLink"
@@ -406,66 +443,114 @@
               </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 role="gridcell">
+            <!-- The content must be in a separate div. It guarantees, that
+              gridcell always visible for screen readers.
+              For example, without a nested div screen readers pronounce the
+              "Commit message" row content with incorrect column headers.
+            -->
+            <div
+              class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
+              aria-label="A bar that represents the addition and deletion ratio for the current file"
+            >
+              <svg width="61" height="8">
+                <rect
+                  x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--positive-green-text-color)"
+                  width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+                <rect
+                  x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--negative-red-text-color)"
+                  width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+              </svg>
+            </div>
           </div>
-          <div
-            class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
-            aria-label="A bar that represents the addition and deletion ratio for the current file"
-            tabindex="0"
-          >
-            <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 class$="[[_computeClass('stats', file.__path)]]">
-            <span
-              class="added"
-              tabindex="0"
-              aria-label$="[[file.lines_inserted]] lines added"
-              hidden$="[[file.binary]]"
-            >
-              +[[file.lines_inserted]]
-            </span>
-            <span
-              class="removed"
-              tabindex="0"
-              aria-label$="[[file.lines_deleted]] lines removed"
-              hidden$="[[file.binary]]"
-            >
-              -[[file.lines_deleted]]
-            </span>
-            <span
-              class$="[[_computeBinaryClass(file.size_delta)]]"
-              hidden$="[[!file.binary]]"
-            >
-              [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
-              file.size_delta)]]
-            </span>
+          <div 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]]">
@@ -474,8 +559,10 @@
               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]]">
@@ -486,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)]]"
@@ -512,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
@@ -560,14 +673,14 @@
       <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>
@@ -580,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>
@@ -590,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_html.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
index 2faac52..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,7 +67,7 @@
     }
   </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
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 be53652..e065008 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -35,7 +35,6 @@
 
 const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
-const MSG_PREFIX = '#message-';
 
 /**
  * @extends PolymerElement
@@ -153,10 +152,6 @@
         value: false,
       },
       _isCleanerLogExperimentEnabled: Boolean,
-      _changeMessageUrl: {
-        type: String,
-        computed: '_computeChangeMessageUrl(message)',
-      },
     };
   }
 
@@ -411,10 +406,13 @@
     return classes.join(' ');
   }
 
-  _computeChangeMessageUrl(message) {
-    if (!message) return '';
-    const hash = MSG_PREFIX + message.id;
-    return hash;
+  _handleAnchorClick(e) {
+    e.preventDefault();
+    this.dispatchEvent(new CustomEvent('message-anchor-tap', {
+      bubbles: true,
+      composed: true,
+      detail: {id: this.message.id},
+    }));
   }
 
   _handleReplyTap(e) {
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 baaf5a3..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
@@ -181,9 +181,6 @@
         content: 'PS ';
       }
     }
-    .message-link {
-      text-decoration: none;
-    }
   </style>
   <div class$="[[_computeClass(_expanded)]]">
     <div class="contentContainer">
@@ -303,20 +300,13 @@
           </span>
         </template>
         <template is="dom-if" if="[[message.id]]">
-          <span class="date">
+          <span class="date" on-click="_handleAnchorClick">
             <gr-date-formatter
               has-tooltip=""
               show-date-and-time=""
               date-str="[[message.date]]"
             ></gr-date-formatter>
           </span>
-          <a class="message-link" href="[[_changeMessageUrl]]">
-            <iron-icon
-              id="icon"
-              class="link-icon"
-              icon="gr-icons:link"
-            ></iron-icon>
-          </a>
         </template>
         <iron-icon
           id="expandToggle"
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 0bd53c7..332ca52 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -222,6 +222,26 @@
       });
     });
 
+    test('clicking on date link fires event', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        id: '47c43261_55aa2c41',
+        expanded: false,
+      };
+      flushAsynchronousOperations();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+    });
+
     suite('compute messages', () => {
       test('empty', () => {
         assert.equal(element._computeMessageContent('', '', true), '');
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 5fd64d2..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
@@ -111,6 +111,7 @@
     filter="_isMessageVisible"
   >
     <gr-message
+      change="[[change]]"
       change-num="[[changeNum]]"
       message="[[message]]"
       project-name="[[projectName]]"
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 6aa7ffc..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 */
     }
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 b808bd9..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 */
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 989f97e..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
@@ -82,8 +83,69 @@
     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:
+   *  - 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 {Array<Object>} threads
    * @param {!Object} spliceRecord
@@ -105,6 +167,7 @@
         && !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);
@@ -115,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 7f3af7c..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,28 +509,20 @@
     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', () => {
@@ -373,7 +531,7 @@
   });
 
   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 = [];
@@ -381,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-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
index 8308942..69e2989 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
@@ -393,13 +393,12 @@
    * @param {number=} opt_patchNum
    * @return {string}
    */
-  getUrlForChangeById(changeNum, project, opt_patchNum, opt_messageHash) {
+  getUrlForChangeById(changeNum, project, opt_patchNum) {
     return this._getUrlFor({
       view: GerritNav.View.CHANGE,
       changeNum,
       project,
       patchNum: opt_patchNum,
-      messageHash: opt_messageHash,
     });
   },
 
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-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 47139ce..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>
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_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 bdac50f..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';
 
 /**
@@ -113,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-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 8b3ef8a..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,6 +273,10 @@
       >
         <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
       </gr-button>
+      <template is="dom-if" if="[[showPatchset]]">
+        <span class="patchset-text"> Patchset [[patchNum]]</span>
+      </template>
+      <span class="separator"></span>
       <span class="date" tabindex="0" on-click="_handleAnchorClick">
         <gr-date-formatter
           has-tooltip=""
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 254fd25..070ac02 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
@@ -102,8 +102,8 @@
       <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 material.io https://material.io/resources/icons/?icon=link&style=baseline-->
-      <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></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-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/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
index 6b6ed98..5aaea30 100644
--- a/polygerrit-ui/app/samples/repo-command.js
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -33,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() {
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/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/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/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/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 61b12f1..d49e700 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -40,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,
@@ -64,7 +67,7 @@
             "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_name.upper(),
             "cd $$TMP",
             "unzip -q $$ROOT/$<",
-            "echo \"Implementation-Version: $$GEN_VERSION\nGerrit-ApiVersion: " + GERRIT_VERSION + "\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
+            "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
             "find . -exec touch '{}' ';'",
             "zip -Xqr $$ROOT/$@ .",
         ]),
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/nongoogle.bzl b/tools/nongoogle.bzl
index c37ce12..1da5004 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -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(