Merge "Fix in-product help text"
diff --git a/.mailmap b/.mailmap
index f2fe6ca..cbf1f3b 100644
--- a/.mailmap
+++ b/.mailmap
@@ -38,6 +38,7 @@
 Johan Björk <jbjoerk@gmail.com>                                                             Johan Bjork <phb@spotify.com>
 JT Olds <hello@jtolds.com>                                                                  <jtolds@gmail.com>
 Kasper Nilsson <kaspern@google.com>                                                         <kaspern@google.com>
+Lawrence Dubé <ldube@audiokinetic.com>                                                      <ldube@audiokinetic.com>
 Lei Sun <lei.sun01@sap.com>                                                                 LeiSun <lei.sun01@sap.com>
 Lincoln Oliveira Campos Do Nascimento <lincoln.oliveiracamposdonascimento@sonyericsson.com> lincoln <lincoln.oliveiracamposdonascimento@sonyericsson.com>
 Luca Milanesio <luca.milanesio@gmail.com>                                                   <luca@gitent-scm.com>
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 589071a..e13a9b9 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4654,103 +4654,6 @@
 +
 By default 0.
 
-[[theme]]
-=== Section theme
-
-[[theme.backgroundColor]]theme.backgroundColor::
-+
-_(GWT UI only)_ Background color for the page, and major data tables like the all
-open changes table or the account dashboard. The value must be a
-valid HTML hex color code, or standard color name.
-+
-By default white, `FFFFFF`.
-
-[[theme.topMenuColor]]theme.topMenuColor::
-+
-_(GWT UI only)_ This is the color of the main menu bar at the top of the page.
-The value must be a valid HTML hex color code, or standard color
-name.
-+
-By default white, `FFFFFF`.
-
-[[theme.textColor]]theme.textColor::
-+
-_(GWT UI only)_ Text color for the page, and major data tables like the all open
-changes table or the account dashboard. The value must be a valid HTML hex color
-code, or standard color name.
-+
-By default dark grey, `353535`.
-
-[[theme.trimColor]]theme.trimColor::
-+
-_(GWT UI only)_ Primary color used as a background color behind text.  This is
-the color of the main menu bar at the top, of table headers, and of major UI
-areas that we want to offset from other portions of the page.  The value must be
-a valid HTML hex color code, or standard color name.
-+
-By default a light grey, `EEEEEE`.
-
-[[theme.selectionColor]]theme.selectionColor::
-+
-_(GWT UI only)_ Background color used within a trimColor area to denote the
-currently selected tab, or the background color used in a table to denote the
-currently selected row.  The value must be a valid HTML hex color code, or
-standard color name.
-+
-By default a pale blue, `D8EDF9`.
-
-[[theme.changeTableOutdatedColor]]theme.changeTableOutdatedColor::
-+
-_(GWT UI only)_ Background color used for patch outdated messages.  The value
-must be a valid HTML hex color code, or standard color name.
-+
-By default a shade of red, `F08080`.
-
-[[theme.tableOddRowColor]]theme.tableOddRowColor::
-+
-_(GWT UI only)_ Background color for tables such as lists of open reviews for
-odd rows.  This is so you can have a different color for odd and even rows of
-the table.  The value must be a valid HTML hex color code, or standard color
-name.
-+
-By default transparent.
-
-[[theme.tableEvenRowColor]]theme.tableEvenRowColor::
-+
-_(GWT UI only)_ Background color for tables such as lists of open reviews for
-even rows.  This is so you can have a different color for odd and even rows of
-the table.  The value must be a valid HTML hex color code, or standard color
-name.
-+
-By default transparent.
-
-A different theme may be used for signed-in vs. signed-out user status
-by using the "signed-in" and "signed-out" theme sections. Variables
-not specified in a section are inherited from the default theme.
-
-----
-[theme]
-  backgroundColor = FFFFFF
-[theme "signed-in"]
-  backgroundColor = C0C0C0
-[theme "signed-out"]
-  backgroundColor = 00FFFF
-----
-
-As example, here is the theme configuration to have the old green look:
-
-----
-[theme]
-  backgroundColor = FCFEEF
-  textColor = 000000
-  trimColor = D4E9A9
-  selectionColor = FFFFCC
-  topMenuColor = D4E9A9
-  changeTableOutdatedColor = F08080
-[theme "signed-in"]
-  backgroundColor = FFFFFF
-----
-
 [[trackingid]]
 === Section trackingid
 
@@ -5095,10 +4998,6 @@
 
 The format is one Base-64 encoded public key per line.
 
-== Configuring the Polygerrit UI
-
-Please see link:dev-polygerrit.html[UI] on configuring the Polygerrit UI.
-
 === Configurable Parameters
 
 site_path::
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index d5ffda2..d7bd8b3 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -801,6 +801,64 @@
   }
 ----
 
+=== Calling Command Options ===
+
+Within an OptionHandler, during the processing of an option, plugins can
+provide and call extra parameters on the current command during parsing
+simulating as if they had been passed from the command line originally.
+
+To call additional parameters from within an option handler, instantiate
+the com.google.gerrit.util.cli.CmdLineParser.Parameters class with the
+existing parameters, and then call callParameters() with the additional
+parameters to be parsed. OptionHandlers may optionally pass this class to
+other methods which may then both parse/consume more parameters and call
+additional parameters.
+
+When calling command options not provided by your plugin, there is always
+a risk that the options may not exist, perhaps because the options being
+called are to be provided by another plugin, and said plugin is not
+currently installed. To protect againt this situation, it is possible to
+define an option as being dependent on other options using the
+@RequiresOptions() annotation. If the required options are not all not
+currently present, then the dependent option will not be available or
+visible in the help.
+
+The example below shows a plugin that adds a "--special" option (perhaps
+for use with the Query command) that calls (and requires) the
+"--format json" option.
+
+[source, java]
+----
+public class JsonOutputOptionHandler<T> extends OptionHandler<T> {
+  protected com.google.gerrit.util.cli.CmdLineParser.MyParser myParser;
+
+  public JsonOutputOptionHandler(CmdLineParser parser, OptionDef option, Setter<? super T> setter) {
+    super(parser, option, setter);
+    myParser = (com.google.gerrit.util.cli.CmdLineParser.MyParser) owner;
+  }
+
+  @Override
+  public int parseArguments(org.kohsuke.args4j.spi.Parameters params) throws CmdLineException {
+    new Parameters(params, myParser).callParameters("--format", "json");
+    setter.addValue(true);
+    return 0; // we didn't consume any additional args
+  }
+
+  @Override
+  public String getDefaultMetaVariable() {
+   ...
+  }
+}
+
+@RequiresOptions("--format")
+@Option(
+  name = "--special",
+  usage = "ouptut results using json",
+  handler = JsonOutputOptionHandler.class
+)
+boolean json;
+----
+
 [[query_attributes]]
 === Query Attributes ===
 
diff --git a/Documentation/dev-polygerrit.txt b/Documentation/dev-polygerrit.txt
deleted file mode 100644
index 5621d32..0000000
--- a/Documentation/dev-polygerrit.txt
+++ /dev/null
@@ -1,34 +0,0 @@
-= PolyGerrit - GUI
-
-== Configuring
-
-By default both GWT and PolyGerrit UI are available to users.
-
-To make PolyGerrit the default UI but keep GWT as a secondary UI:
-----
-[gerrit]
-        ui = POLYGERRIT
-----
-
-To disable GWT but not PolyGerrit:
-----
-[gerrit]
-        enableGwtUi = false
-        enablePolyGerrit = true
-----
-
-To enable GWT but not PolyGerrit:
-----
-[gerrit]
-        enableGwtUi = true
-        enablePolyGerrit = false
-----
-
-To switch to the PolyGerrit UI you have to add `?polygerrit=1` in the URL.
-
-for example https://gerrit.example.org/?polygerrit=1
-
-To disable PolyGerrit UI, change 1 to 0, which will take you back to GWT UI.
-
-
-More information can be found in the link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/[README]
diff --git a/WORKSPACE b/WORKSPACE
index 50714f1..8c8102b 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1016,8 +1016,8 @@
 # and httpasyncclient as necessary.
 maven_jar(
     name = "elasticsearch-rest-client",
-    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.4.3",
-    sha1 = "5c24325430971ba2fa4769eb446f026b7680d5e7",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.5.0",
+    sha1 = "241436d27cf65b84d17126dc7b6b947e8e2c173c",
 )
 
 JACKSON_VERSION = "2.9.7"
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index fec7137..6da19cd 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -373,7 +373,7 @@
         List<T> results = Collections.emptyList();
         String uri = getURI(index, SEARCH);
         Response response =
-            performRequest(HttpPost.METHOD_NAME, search, uri, Collections.emptyMap());
+            performRequest(HttpPost.METHOD_NAME, uri, search, Collections.emptyMap());
         StatusLine statusLine = response.getStatusLine();
         if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
           String content = getContent(response);
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index 05fd7a7..65d2916 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -40,6 +40,7 @@
       case V6_2:
       case V6_3:
       case V6_4:
+      case V6_5:
         this.searchFilteringName = "_source";
         this.indicesExistParam = "?allow_no_indices=false";
         this.exactFieldType = "keyword";
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index dfa5d21..4c98df1 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -22,7 +22,8 @@
   V5_6("5.6.*"),
   V6_2("6.2.*"),
   V6_3("6.3.*"),
-  V6_4("6.4.*");
+  V6_4("6.4.*"),
+  V6_5("6.5.*");
 
   private final String version;
   private final Pattern pattern;
diff --git a/java/com/google/gerrit/extensions/client/UiType.java b/java/com/google/gerrit/extensions/client/UiType.java
deleted file mode 100644
index 0d9df39..0000000
--- a/java/com/google/gerrit/extensions/client/UiType.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.client;
-
-public enum UiType {
-  NONE,
-  GWT,
-  POLYGERRIT;
-
-  public static UiType parse(String str) {
-    if (str != null) {
-      for (UiType type : UiType.values()) {
-        if (type.name().equalsIgnoreCase(str)) {
-          return type;
-        }
-      }
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index f904b06..e825f2e 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.extensions.common;
 
-import com.google.gerrit.extensions.client.UiType;
-import java.util.Set;
-
 public class GerritInfo {
   public String allProjects;
   public String allUsers;
@@ -25,5 +22,4 @@
   public Boolean editGpgKeys;
   public String reportBugUrl;
   public String reportBugText;
-  public Set<UiType> webUis;
 }
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index 1739de9..4ea31da 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -245,7 +245,7 @@
   }
 
   private void verifyInstallPluginList(ConsoleUI ui, List<PluginData> plugins) {
-    if (nullOrEmpty(installPlugins) || nullOrEmpty(plugins)) {
+    if (nullOrEmpty(installPlugins)) {
       return;
     }
     Set<String> missing = Sets.newHashSet(installPlugins);
diff --git a/java/com/google/gerrit/server/AccessPath.java b/java/com/google/gerrit/server/AccessPath.java
index cb720c8..4d07d62 100644
--- a/java/com/google/gerrit/server/AccessPath.java
+++ b/java/com/google/gerrit/server/AccessPath.java
@@ -22,9 +22,6 @@
   /** Access through the REST API. */
   REST_API,
 
-  /** Access through the old JSON-RPC interface. */
-  JSON_RPC,
-
   /** Access by a web cookie. This path is not protected like REST_API. */
   WEB_BROWSER,
 
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 3759f09..dc5a262 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -50,8 +50,18 @@
    *   }
    * </pre>
    *
-   * The option will be prefixed by the plugin name. In the example above, if the plugin name was
+   * <p>The option will be prefixed by the plugin name. In the example above, if the plugin name was
    * my-plugin, then the --verbose option as used by the caller would be --my-plugin--verbose.
+   *
+   * <p>Additional options can be annotated with @RequiresOption which will cause them to be ignored
+   * unless the required option is present. For example:
+   *
+   * <pre>
+   *   {@literal @}RequiresOptions("--help")
+   *   {@literal @}Option(name = "--help-as-json",
+   *           usage = "display help text in json format")
+   *   public boolean displayHelpAsJson;
+   * </pre>
    */
   public interface DynamicBean {}
 
@@ -261,6 +271,7 @@
     for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
       clp.parseWithPrefix("--" + e.getKey(), e.getValue());
     }
+    clp.drainOptionQueue();
   }
 
   public void setDynamicBeans() {
diff --git a/java/com/google/gerrit/server/audit/RpcAuditEvent.java b/java/com/google/gerrit/server/audit/RpcAuditEvent.java
deleted file mode 100644
index 6c53bb2..0000000
--- a/java/com/google/gerrit/server/audit/RpcAuditEvent.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.audit;
-
-import com.google.common.collect.ListMultimap;
-import com.google.gerrit.server.CurrentUser;
-
-public class RpcAuditEvent extends HttpAuditEvent {
-
-  /**
-   * Creates a new audit event with results
-   *
-   * @param sessionId session id the event belongs to
-   * @param who principal that has generated the event
-   * @param what object of the event
-   * @param when time-stamp of when the event started
-   * @param params parameters of the event
-   * @param httpMethod HTTP method
-   * @param input input
-   * @param status HTTP status
-   * @param result result of the event
-   */
-  public RpcAuditEvent(
-      String sessionId,
-      CurrentUser who,
-      String what,
-      long when,
-      ListMultimap<String, ?> params,
-      String httpMethod,
-      Object input,
-      int status,
-      Object result) {
-    super(sessionId, who, what, when, params, httpMethod, input, status, result);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/IntBlob.java b/java/com/google/gerrit/server/notedb/IntBlob.java
index 9bac2a4..f8c713c 100644
--- a/java/com/google/gerrit/server/notedb/IntBlob.java
+++ b/java/com/google/gerrit/server/notedb/IntBlob.java
@@ -18,6 +18,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
@@ -28,6 +29,7 @@
 import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectLoader;
@@ -116,8 +118,9 @@
     return result == RefUpdate.Result.NEW || result == RefUpdate.Result.FORCED;
   }
 
-  private static IntBlob create(ObjectId id, int value) {
-    return new AutoValue_IntBlob(id, value);
+  @VisibleForTesting
+  static IntBlob create(AnyObjectId id, int value) {
+    return new AutoValue_IntBlob(id.copy(), value);
   }
 
   public abstract ObjectId id();
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 74b04a3..83ea7f8 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -197,7 +197,6 @@
       case GIT:
         return false;
 
-      case JSON_RPC:
       case REST_API:
       case SSH_COMMAND:
       case UNKNOWN:
@@ -229,7 +228,6 @@
       case GIT:
         return canPushWithForce() || canPerform(Permission.DELETE);
 
-      case JSON_RPC:
       case REST_API:
       case SSH_COMMAND:
       case UNKNOWN:
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
new file mode 100644
index 0000000..b50b046
--- /dev/null
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -0,0 +1,255 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+public class ProjectCreator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final PluginSetContext<NewProjectCreatedListener> createdListeners;
+  private final ProjectCache projectCache;
+  private final GroupBackend groupBackend;
+  private final MetaDataUpdate.User metaDataUpdateFactory;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RepositoryConfig repositoryCfg;
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final ProjectConfig.Factory projectConfigFactory;
+
+  @Inject
+  ProjectCreator(
+      GitRepositoryManager repoManager,
+      PluginSetContext<NewProjectCreatedListener> createdListeners,
+      ProjectCache projectCache,
+      GroupBackend groupBackend,
+      MetaDataUpdate.User metaDataUpdateFactory,
+      GitReferenceUpdated referenceUpdated,
+      RepositoryConfig repositoryCfg,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      Provider<IdentifiedUser> identifiedUser,
+      ProjectConfig.Factory projectConfigFactory) {
+    this.repoManager = repoManager;
+    this.createdListeners = createdListeners;
+    this.projectCache = projectCache;
+    this.groupBackend = groupBackend;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.referenceUpdated = referenceUpdated;
+    this.repositoryCfg = repositoryCfg;
+    this.serverIdent = serverIdent;
+    this.identifiedUser = identifiedUser;
+    this.projectConfigFactory = projectConfigFactory;
+  }
+
+  public ProjectState createProject(CreateProjectArgs args)
+      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
+    final Project.NameKey nameKey = args.getProject();
+    try {
+      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
+      try (Repository repo = repoManager.openRepository(nameKey)) {
+        if (repo.getObjectDatabase().exists()) {
+          throw new ResourceConflictException("project \"" + nameKey + "\" exists");
+        }
+      } catch (RepositoryNotFoundException e) {
+        // It does not exist, safe to ignore.
+      }
+      try (Repository repo = repoManager.createRepository(nameKey)) {
+        RefUpdate u = repo.updateRef(Constants.HEAD);
+        u.disableRefLog();
+        u.link(head);
+
+        createProjectConfig(args);
+
+        if (!args.permissionsOnly && args.createEmptyCommit) {
+          createEmptyCommits(repo, nameKey, args.branch);
+        }
+
+        fire(nameKey, head);
+
+        return projectCache.get(nameKey);
+      }
+    } catch (RepositoryCaseMismatchException e) {
+      throw new ResourceConflictException(
+          "Cannot create "
+              + nameKey.get()
+              + " because the name is already occupied by another project."
+              + " The other project has the same name, only spelled in a"
+              + " different case.");
+    } catch (RepositoryNotFoundException badName) {
+      throw new BadRequestException("invalid project name: " + nameKey);
+    } catch (ConfigInvalidException e) {
+      String msg = "Cannot create " + nameKey;
+      logger.atSevere().withCause(e).log(msg);
+      throw e;
+    }
+  }
+
+  private void createProjectConfig(CreateProjectArgs args)
+      throws IOException, ConfigInvalidException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      Project newProject = config.getProject();
+      newProject.setDescription(args.projectDescription);
+      newProject.setSubmitType(
+          MoreObjects.firstNonNull(
+              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
+      newProject.setBooleanConfig(
+          BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
+      newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
+      newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
+      newProject.setBooleanConfig(
+          BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+          args.newChangeForAllNotInTarget);
+      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
+      newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
+      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
+      newProject.setBooleanConfig(BooleanProjectConfig.ENABLE_SIGNED_PUSH, args.enableSignedPush);
+      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_SIGNED_PUSH, args.requireSignedPush);
+      if (args.newParent != null) {
+        newProject.setParentName(args.newParent);
+      }
+
+      if (!args.ownerIds.isEmpty()) {
+        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
+        for (AccountGroup.UUID ownerId : args.ownerIds) {
+          GroupDescription.Basic g = groupBackend.get(ownerId);
+          if (g != null) {
+            GroupReference group = config.resolve(GroupReference.forGroup(g));
+            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
+          }
+        }
+      }
+
+      md.setMessage("Created project\n");
+      config.commit(md);
+      md.getRepository().setGitwebDescription(args.projectDescription);
+    }
+    projectCache.onCreateProject(args.getProject());
+  }
+
+  private void createEmptyCommits(Repository repo, Project.NameKey project, List<String> refs)
+      throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
+      cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
+      cb.setCommitter(serverIdent.get());
+      cb.setMessage("Initial empty repository\n");
+
+      ObjectId id = oi.insert(cb);
+      oi.flush();
+
+      for (String ref : refs) {
+        RefUpdate ru = repo.updateRef(ref);
+        ru.setNewObjectId(id);
+        Result result = ru.update();
+        switch (result) {
+          case NEW:
+            referenceUpdated.fire(
+                project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+            break;
+          case FAST_FORWARD:
+          case FORCED:
+          case IO_FAILURE:
+          case LOCK_FAILURE:
+          case NOT_ATTEMPTED:
+          case NO_CHANGE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            {
+              throw new IOException(
+                  String.format("Failed to create ref \"%s\": %s", ref, result.name()));
+            }
+        }
+      }
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Cannot create empty commit for %s", project.get());
+      throw e;
+    }
+  }
+
+  private void fire(Project.NameKey name, String head) {
+    if (createdListeners.isEmpty()) {
+      return;
+    }
+
+    ProjectCreator.Event event = new ProjectCreator.Event(name, head);
+    createdListeners.runEach(l -> l.onNewProjectCreated(event));
+  }
+
+  static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
+    private final Project.NameKey name;
+    private final String head;
+
+    Event(Project.NameKey name, String head) {
+      this.name = name;
+      this.head = head;
+    }
+
+    @Override
+    public String getProjectName() {
+      return name.get();
+    }
+
+    @Override
+    public String getHeadName() {
+      return head;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 9a49ffe..ac78aef 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,6 +17,8 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.index.IndexConfig;
@@ -68,6 +70,8 @@
   private final Provider<CurrentUser> userProvider;
   private final ChangeNotes.Factory notesFactory;
   private final DynamicMap<ChangeAttributeFactory> attributeFactories;
+  private final Multimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin =
+      HashMultimap.create();
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final Provider<AnonymousUser> anonymousUserProvider;
@@ -109,6 +113,7 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.anonymousUserProvider = anonymousUserProvider;
+    setupAttributeFactories();
   }
 
   @Override
@@ -132,22 +137,29 @@
     return dynamicBeans.get(plugin);
   }
 
-  @Override
-  public List<PluginDefinedInfo> create(ChangeData cd) {
-    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
+  public void setupAttributeFactories() {
     for (String plugin : attributeFactories.plugins()) {
       for (Provider<ChangeAttributeFactory> provider :
           attributeFactories.byPlugin(plugin).values()) {
-        PluginDefinedInfo pda = null;
-        try {
-          pda = provider.get().create(cd, this, plugin);
-        } catch (RuntimeException e) {
-          /* Eat runtime exceptions so that queries don't fail. */
-        }
-        if (pda != null) {
-          pda.name = plugin;
-          plugins.add(pda);
-        }
+        attributeFactoriesByPlugin.put(plugin, provider.get());
+      }
+    }
+  }
+
+  @Override
+  public List<PluginDefinedInfo> create(ChangeData cd) {
+    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
+    for (Map.Entry<String, ChangeAttributeFactory> e : attributeFactoriesByPlugin.entries()) {
+      String plugin = e.getKey();
+      PluginDefinedInfo pda = null;
+      try {
+        pda = e.getValue().create(cd, this, plugin);
+      } catch (RuntimeException ex) {
+        /* Eat runtime exceptions so that queries don't fail. */
+      }
+      if (pda != null) {
+        pda.name = plugin;
+        plugins.add(pda);
       }
     }
     if (plugins.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 963a1b4..98ef220 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -20,7 +20,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.extensions.client.UiType;
 import com.google.gerrit.extensions.common.AccountsInfo;
 import com.google.gerrit.extensions.common.AuthInfo;
 import com.google.gerrit.extensions.common.ChangeConfigInfo;
@@ -66,7 +65,6 @@
 import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -304,7 +302,6 @@
     info.docSearch = docSearcher.isAvailable();
     info.editGpgKeys =
         toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
-    info.webUis = EnumSet.noneOf(UiType.class);
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 60a24d8..3785784 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -19,21 +19,14 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.ProjectUtil;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ProjectInfo;
-import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -41,29 +34,17 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
-import com.google.gerrit.server.config.RepositoryConfig;
-import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.RepositoryCaseMismatchException;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.CreateProjectArgs;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectCreator;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectResource;
@@ -79,84 +60,47 @@
 import java.util.List;
 import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 @RequiresCapability(GlobalCapability.CREATE_PROJECT)
 @Singleton
 public class CreateProject
     implements RestCollectionCreateView<TopLevelResource, ProjectResource, ProjectInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final Provider<ProjectsCollection> projectsCollection;
   private final Provider<GroupResolver> groupResolver;
   private final PluginSetContext<ProjectCreationValidationListener>
       projectCreationValidationListeners;
   private final ProjectJson json;
-  private final GitRepositoryManager repoManager;
-  private final PluginSetContext<NewProjectCreatedListener> createdListeners;
-  private final ProjectCache projectCache;
-  private final GroupBackend groupBackend;
   private final ProjectOwnerGroupsProvider.Factory projectOwnerGroups;
-  private final MetaDataUpdate.User metaDataUpdateFactory;
-  private final GitReferenceUpdated referenceUpdated;
-  private final RepositoryConfig repositoryCfg;
-  private final Provider<PersonIdent> serverIdent;
-  private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
   private final PluginItemContext<ProjectNameLockManager> lockManager;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCreator projectCreator;
 
   @Inject
   CreateProject(
+      ProjectCreator projectCreator,
       Provider<ProjectsCollection> projectsCollection,
       Provider<GroupResolver> groupResolver,
       ProjectJson json,
       PluginSetContext<ProjectCreationValidationListener> projectCreationValidationListeners,
-      GitRepositoryManager repoManager,
-      PluginSetContext<NewProjectCreatedListener> createdListeners,
-      ProjectCache projectCache,
-      GroupBackend groupBackend,
       ProjectOwnerGroupsProvider.Factory projectOwnerGroups,
-      MetaDataUpdate.User metaDataUpdateFactory,
-      GitReferenceUpdated referenceUpdated,
-      RepositoryConfig repositoryCfg,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<IdentifiedUser> identifiedUser,
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      PluginItemContext<ProjectNameLockManager> lockManager,
-      ProjectConfig.Factory projectConfigFactory) {
+      PluginItemContext<ProjectNameLockManager> lockManager) {
     this.projectsCollection = projectsCollection;
+    this.projectCreator = projectCreator;
     this.groupResolver = groupResolver;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.json = json;
-    this.repoManager = repoManager;
-    this.createdListeners = createdListeners;
-    this.projectCache = projectCache;
-    this.groupBackend = groupBackend;
     this.projectOwnerGroups = projectOwnerGroups;
-    this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.referenceUpdated = referenceUpdated;
-    this.repositoryCfg = repositoryCfg;
-    this.serverIdent = serverIdent;
-    this.identifiedUser = identifiedUser;
     this.putConfig = putConfig;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
     this.lockManager = lockManager;
-    this.projectConfigFactory = projectConfigFactory;
   }
 
   @Override
@@ -227,7 +171,7 @@
         throw new ResourceConflictException(e.getMessage(), e);
       }
 
-      ProjectState projectState = createProject(args);
+      ProjectState projectState = projectCreator.createProject(args);
       requireNonNull(
           projectState,
           () -> String.format("failed to create project %s", args.getProject().get()));
@@ -243,93 +187,6 @@
     }
   }
 
-  private ProjectState createProject(CreateProjectArgs args)
-      throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
-    final Project.NameKey nameKey = args.getProject();
-    try {
-      final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
-      try (Repository repo = repoManager.openRepository(nameKey)) {
-        if (repo.getObjectDatabase().exists()) {
-          throw new ResourceConflictException("project \"" + nameKey + "\" exists");
-        }
-      } catch (RepositoryNotFoundException e) {
-        // It does not exist, safe to ignore.
-      }
-      try (Repository repo = repoManager.createRepository(nameKey)) {
-        RefUpdate u = repo.updateRef(Constants.HEAD);
-        u.disableRefLog();
-        u.link(head);
-
-        createProjectConfig(args);
-
-        if (!args.permissionsOnly && args.createEmptyCommit) {
-          createEmptyCommits(repo, nameKey, args.branch);
-        }
-
-        fire(nameKey, head);
-
-        return projectCache.get(nameKey);
-      }
-    } catch (RepositoryCaseMismatchException e) {
-      throw new ResourceConflictException(
-          "Cannot create "
-              + nameKey.get()
-              + " because the name is already occupied by another project."
-              + " The other project has the same name, only spelled in a"
-              + " different case.");
-    } catch (RepositoryNotFoundException badName) {
-      throw new BadRequestException("invalid project name: " + nameKey);
-    } catch (ConfigInvalidException e) {
-      String msg = "Cannot create " + nameKey;
-      logger.atSevere().withCause(e).log(msg);
-      throw e;
-    }
-  }
-
-  private void createProjectConfig(CreateProjectArgs args)
-      throws IOException, ConfigInvalidException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      Project newProject = config.getProject();
-      newProject.setDescription(args.projectDescription);
-      newProject.setSubmitType(
-          MoreObjects.firstNonNull(
-              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-          args.newChangeForAllNotInTarget);
-      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
-      newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
-      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
-      newProject.setBooleanConfig(BooleanProjectConfig.ENABLE_SIGNED_PUSH, args.enableSignedPush);
-      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_SIGNED_PUSH, args.requireSignedPush);
-      if (args.newParent != null) {
-        newProject.setParentName(args.newParent);
-      }
-
-      if (!args.ownerIds.isEmpty()) {
-        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-        for (AccountGroup.UUID ownerId : args.ownerIds) {
-          GroupDescription.Basic g = groupBackend.get(ownerId);
-          if (g != null) {
-            GroupReference group = config.resolve(GroupReference.forGroup(g));
-            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
-          }
-        }
-      }
-
-      md.setMessage("Created project\n");
-      config.commit(md);
-      md.getRepository().setGitwebDescription(args.projectDescription);
-    }
-    projectCache.onCreateProject(args.getProject());
-  }
-
   private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
       return Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
@@ -350,78 +207,4 @@
     }
     return normalizedBranches;
   }
-
-  private void createEmptyCommits(Repository repo, Project.NameKey project, List<String> refs)
-      throws IOException {
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      CommitBuilder cb = new CommitBuilder();
-      cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
-      cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
-      cb.setCommitter(serverIdent.get());
-      cb.setMessage("Initial empty repository\n");
-
-      ObjectId id = oi.insert(cb);
-      oi.flush();
-
-      for (String ref : refs) {
-        RefUpdate ru = repo.updateRef(ref);
-        ru.setNewObjectId(id);
-        Result result = ru.update();
-        switch (result) {
-          case NEW:
-            referenceUpdated.fire(
-                project, ru, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
-            break;
-          case FAST_FORWARD:
-          case FORCED:
-          case IO_FAILURE:
-          case LOCK_FAILURE:
-          case NOT_ATTEMPTED:
-          case NO_CHANGE:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            {
-              throw new IOException(
-                  String.format("Failed to create ref \"%s\": %s", ref, result.name()));
-            }
-        }
-      }
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Cannot create empty commit for %s", project.get());
-      throw e;
-    }
-  }
-
-  private void fire(Project.NameKey name, String head) {
-    if (createdListeners.isEmpty()) {
-      return;
-    }
-
-    Event event = new Event(name, head);
-    createdListeners.runEach(l -> l.onNewProjectCreated(event));
-  }
-
-  static class Event extends AbstractNoNotifyEvent implements NewProjectCreatedListener.Event {
-    private final Project.NameKey name;
-    private final String head;
-
-    Event(Project.NameKey name, String head) {
-      this.name = name;
-      this.head = head;
-    }
-
-    @Override
-    public String getProjectName() {
-      return name.get();
-    }
-
-    @Override
-    public String getHeadName() {
-      return head;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/schema/Schema_151.java b/java/com/google/gerrit/server/schema/Schema_151.java
index d244018..0e8700f 100644
--- a/java/com/google/gerrit/server/schema/Schema_151.java
+++ b/java/com/google/gerrit/server/schema/Schema_151.java
@@ -60,7 +60,7 @@
       PreparedStatement addedOnRetrieval, AccountGroup.Id groupId) throws SQLException {
     addedOnRetrieval.setInt(1, groupId.get());
     try (ResultSet resultSet = addedOnRetrieval.executeQuery()) {
-      if (resultSet.first()) {
+      if (resultSet.next()) {
         return Optional.of(resultSet.getTimestamp(1));
       }
     }
diff --git a/java/com/google/gerrit/server/schema/Schema_87.java b/java/com/google/gerrit/server/schema/Schema_87.java
index fe21565..79884ba 100644
--- a/java/com/google/gerrit/server/schema/Schema_87.java
+++ b/java/com/google/gerrit/server/schema/Schema_87.java
@@ -59,7 +59,7 @@
       PreparedStatement uuidRetrieval, AccountGroup.Id id) throws SQLException {
     uuidRetrieval.setInt(1, id.get());
     try (ResultSet uuidResults = uuidRetrieval.executeQuery()) {
-      if (uuidResults.first()) {
+      if (uuidResults.next()) {
         Optional.of(new AccountGroup.UUID(uuidResults.getString(1)));
       }
     }
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 5b7ea3f..555abc3 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -50,8 +50,11 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.ResourceBundle;
@@ -85,6 +88,89 @@
     CmdLineParser create(Object bean);
   }
 
+  /**
+   * This may be used by an option handler during parsing to "call" additional parameters simulating
+   * as if they had been passed from the command line originally.
+   *
+   * <p>To call additional parameters from within an option handler, instantiate this class with the
+   * parameters and then call callParameters() with the additional parameters to be parsed.
+   * OptionHandlers may optionally pass this class to other methods which may then both
+   * parse/consume more parameters and call additional parameters.
+   */
+  public static class Parameters implements org.kohsuke.args4j.spi.Parameters {
+    protected final String[] args;
+    protected MyParser parser;
+    protected int consumed = 0;
+
+    public Parameters(org.kohsuke.args4j.spi.Parameters args, MyParser parser)
+        throws CmdLineException {
+      this.args = new String[args.size()];
+      for (int i = 0; i < args.size(); i++) {
+        this.args[i] = args.getParameter(i);
+      }
+      this.parser = parser;
+    }
+
+    public Parameters(String[] args, MyParser parser) {
+      this.args = args;
+      this.parser = parser;
+    }
+
+    @Override
+    public String getParameter(int idx) throws CmdLineException {
+      return args[idx];
+    }
+
+    /**
+     * get and consume (consider parsed) a parameter
+     *
+     * @return the consumed parameter
+     */
+    public String consumeParameter() throws CmdLineException {
+      return getParameter(consumed++);
+    }
+
+    @Override
+    public int size() {
+      return args.length;
+    }
+
+    /**
+     * Add 'count' to the value of parsed parameters. May be called more than once.
+     *
+     * @param count How many parameters were just parsed.
+     */
+    public void consume(int count) {
+      consumed += count;
+    }
+
+    /**
+     * Reports handlers how many parameters were parsed
+     *
+     * @return the count of parsed parameters
+     */
+    public int getConsumed() {
+      return consumed;
+    }
+
+    /**
+     * Use during parsing to call additional parameters simulating as if they had been passed from
+     * the command line originally.
+     *
+     * @param args A variable amount of parameters to call immediately
+     *     <p>The parameters will be parsed immediately, before the remaining parameter will be
+     *     parsed.
+     *     <p>Note: Since this is done outside of the arg4j parsing loop, it will not match exactly
+     *     what would happen if they were actually passed from the command line, but it will be
+     *     pretty close. If this were moved to args4j, the interface could be the same and it could
+     *     match exactly the behavior as if passed from the command line originally.
+     */
+    public void callParameters(String... args) throws CmdLineException {
+      Parameters impl = new Parameters(Arrays.copyOfRange(args, 1, args.length), parser);
+      parser.findOptionByName(args[0]).parseArguments(impl);
+    }
+  }
+
   private final OptionHandlers handlers;
   private final MyParser parser;
 
@@ -270,6 +356,10 @@
     parser.parseWithPrefix(prefix, bean);
   }
 
+  public void drainOptionQueue() {
+    parser.addOptionsWithMetRequirements();
+  }
+
   private String makeOption(String name) {
     if (!name.startsWith("-")) {
       if (name.length() == 1) {
@@ -404,18 +494,64 @@
     }
   }
 
-  private class MyParser extends org.kohsuke.args4j.CmdLineParser {
+  public class MyParser extends org.kohsuke.args4j.CmdLineParser {
     @SuppressWarnings("rawtypes")
     private List<OptionHandler> optionsList;
 
+    private Map<String, QueuedOption> queuedOptionsByName = new LinkedHashMap<>();
     private HelpOption help;
 
+    private class QueuedOption {
+      public final Option option;
+
+      @SuppressWarnings("rawtypes")
+      public final Setter setter;
+
+      public final String[] requiredOptions;
+
+      private QueuedOption(
+          Option option,
+          @SuppressWarnings("rawtypes") Setter setter,
+          RequiresOptions requiresOptions) {
+        this.option = option;
+        this.setter = setter;
+        this.requiredOptions = requiresOptions != null ? requiresOptions.value() : new String[0];
+      }
+    }
+
     MyParser(Object bean) {
       super(bean, ParserProperties.defaults().withAtSyntax(false));
       parseAdditionalOptions(bean, new HashSet<>());
+      addOptionsWithMetRequirements();
       ensureOptionsInitialized();
     }
 
+    public int addOptionsWithMetRequirements() {
+      int count = 0;
+      for (Iterator<Map.Entry<String, QueuedOption>> it = queuedOptionsByName.entrySet().iterator();
+          it.hasNext(); ) {
+        QueuedOption queuedOption = it.next().getValue();
+        if (hasAllRequiredOptions(queuedOption)) {
+          addOption(queuedOption.setter, queuedOption.option);
+          it.remove();
+          count++;
+        }
+      }
+      if (count > 0) {
+        count += addOptionsWithMetRequirements();
+      }
+      return count;
+    }
+
+    private boolean hasAllRequiredOptions(QueuedOption queuedOption) {
+      for (String name : queuedOption.requiredOptions) {
+        if (findOptionByName(name) == null) {
+          return false;
+        }
+      }
+      return true;
+    }
+
     // NOTE: Argument annotations on bean are ignored.
     public void parseWithPrefix(String prefix, Object bean) {
       parseWithPrefix(prefix, bean, new HashSet<>());
@@ -430,13 +566,19 @@
         for (Method m : c.getDeclaredMethods()) {
           Option o = m.getAnnotation(Option.class);
           if (o != null) {
-            addOption(new MethodSetter(this, bean, m), new PrefixedOption(prefix, o));
+            queueOption(
+                new PrefixedOption(prefix, o),
+                new MethodSetter(this, bean, m),
+                m.getAnnotation(RequiresOptions.class));
           }
         }
         for (Field f : c.getDeclaredFields()) {
           Option o = f.getAnnotation(Option.class);
           if (o != null) {
-            addOption(Setters.create(f, bean), new PrefixedOption(prefix, o));
+            queueOption(
+                new PrefixedOption(prefix, o),
+                Setters.create(f, bean),
+                f.getAnnotation(RequiresOptions.class));
           }
           if (f.isAnnotationPresent(Options.class)) {
             try {
@@ -480,6 +622,41 @@
       return add(super.createOptionHandler(option, setter));
     }
 
+    /**
+     * Finds a registered {@code OptionHandler} by its name or its alias.
+     *
+     * @param name name
+     * @return the {@code OptionHandler} or {@code null}
+     *     <p>Note: this is cut & pasted from the parent class in arg4j, it was private and it
+     *     needed to be exposed.
+     */
+    @SuppressWarnings("rawtypes")
+    public OptionHandler findOptionByName(String name) {
+      for (OptionHandler h : optionsList) {
+        NamedOptionDef option = (NamedOptionDef) h.option;
+        if (name.equals(option.name())) {
+          return h;
+        }
+        for (String alias : option.aliases()) {
+          if (name.equals(alias)) {
+            return h;
+          }
+        }
+      }
+      return null;
+    }
+
+    private void queueOption(
+        Option option,
+        @SuppressWarnings("rawtypes") Setter setter,
+        RequiresOptions requiresOptions) {
+      if (queuedOptionsByName.put(option.name(), new QueuedOption(option, setter, requiresOptions))
+          != null) {
+        throw new IllegalAnnotationError(
+            "Option name " + option.name() + " is used more than once");
+      }
+    }
+
     @SuppressWarnings("rawtypes")
     private OptionHandler add(OptionHandler handler) {
       ensureOptionsInitialized();
diff --git a/java/com/google/gerrit/util/cli/RequiresOptions.java b/java/com/google/gerrit/util/cli/RequiresOptions.java
new file mode 100644
index 0000000..de6ba44
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/RequiresOptions.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.cli;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a field/setter annotated with {@literal @}Option as having a dependency on multiple other
+ * command line option.
+ *
+ * <p>If any of the required command line options are not present, the {@literal @}Option will be
+ * ignored.
+ *
+ * <p>For example:
+ *
+ * <pre>
+ *   {@literal @}RequiresOptions({"--help", "--usage"})
+ *   {@literal @}Option(name = "--help-as-json",
+ *           usage = "display help text in json format")
+ *   public boolean displayHelpAsJson;
+ * </pre>
+ */
+@Retention(RUNTIME)
+@Target({FIELD, METHOD, PARAMETER})
+public @interface RequiresOptions {
+  String[] value();
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index ee22141..6f25d28 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -147,7 +147,7 @@
   }
 
   // Creates a group, but with uniquified name.
-  protected String createGroup(String name) throws Exception {
+  protected String createUniqueGroup() throws Exception {
     // TODO(hanwen): rewrite this test in terms of UUID. This requires redoing the assertion helpers
     // too.
     AccountGroup.UUID g = groupOperations.newGroup().ownerGroupUuid(adminGroupUuid()).create();
@@ -155,6 +155,8 @@
   }
 
   protected String createGroup(String name, String owner) throws Exception {
+    // TODO(hanwen): rewrite to use groupOperations. This requires passing the owner
+    // group's UUID rathen than its name.
     name = name(name);
     GroupInput in = new GroupInput();
     in.name = name;
@@ -191,7 +193,7 @@
 
   @Test
   public void addRemoveMember() throws Exception {
-    String g = createGroup("users");
+    String g = createUniqueGroup();
     gApi.groups().id(g).addMembers("user");
     assertMembers(g, user);
 
@@ -206,7 +208,7 @@
 
     // Fill the cache for the observed account.
     groupIncludeCache.getGroupsWithMember(accountId);
-    String groupName = createGroup("users");
+    String groupName = createUniqueGroup();
     AccountGroup.UUID groupUuid = new AccountGroup.UUID(gApi.groups().id(groupName).get().id);
 
     gApi.groups().id(groupName).addMembers(username);
@@ -238,7 +240,7 @@
 
   @Test
   public void addMultipleMembers() throws Exception {
-    String g = createGroup("users");
+    String g = createUniqueGroup();
 
     String u1 = name("u1");
     accountOperations.newAccount().username(u1).create();
@@ -255,7 +257,7 @@
 
   @Test
   public void membersWithAtSignInUsernameCanBeAdded() throws Exception {
-    String g = createGroup("users");
+    String g = createUniqueGroup();
     String usernameWithAt = name("u1@something");
     accountOperations.newAccount().username(usernameWithAt).create();
 
@@ -269,7 +271,7 @@
 
   @Test
   public void membersWithAtSignInUsernameAreNotConfusedWithSimilarUsernames() throws Exception {
-    String g = createGroup("users");
+    String g = createUniqueGroup();
     String usernameWithAt = name("u1@something");
     accountOperations.newAccount().username(usernameWithAt).create();
     String usernameWithoutAt = name("u1something");
@@ -291,8 +293,8 @@
 
   @Test
   public void includeRemoveGroup() throws Exception {
-    String p = createGroup("parent");
-    String g = createGroup("newGroup");
+    String p = createUniqueGroup();
+    String g = createUniqueGroup();
     gApi.groups().id(p).addGroups(g);
     assertIncludes(p, g);
 
@@ -302,7 +304,7 @@
 
   @Test
   public void includeExternalGroup() throws Exception {
-    String g = createGroup("group");
+    String g = createUniqueGroup();
     String subgroupUuid = SystemGroupBackend.REGISTERED_USERS.get();
     gApi.groups().id(g).addGroups(subgroupUuid);
 
@@ -319,8 +321,8 @@
 
   @Test
   public void includeExistingGroup_OK() throws Exception {
-    String p = createGroup("parent");
-    String g = createGroup("newGroup");
+    String p = createUniqueGroup();
+    String g = createUniqueGroup();
     gApi.groups().id(p).addGroups(g);
     assertIncludes(p, g);
     gApi.groups().id(p).addGroups(g);
@@ -329,9 +331,9 @@
 
   @Test
   public void addMultipleIncludes() throws Exception {
-    String p = createGroup("parent");
-    String g1 = createGroup("newGroup1");
-    String g2 = createGroup("newGroup2");
+    String p = createUniqueGroup();
+    String g1 = createUniqueGroup();
+    String g2 = createUniqueGroup();
     List<String> groups = new ArrayList<>();
     groups.add(g1);
     groups.add(g2);
@@ -637,22 +639,22 @@
 
   @Test
   public void listEmptyGroupIncludes() throws Exception {
-    String gx = createGroup("gx");
+    String gx = createUniqueGroup();
     assertThat(gApi.groups().id(gx).includedGroups()).isEmpty();
   }
 
   @Test
   public void includeNonExistingGroup() throws Exception {
-    String gx = createGroup("gx");
+    String gx = createUniqueGroup();
     exception.expect(UnprocessableEntityException.class);
     gApi.groups().id(gx).addGroups("non-existing");
   }
 
   @Test
   public void listNonEmptyGroupIncludes() throws Exception {
-    String gx = createGroup("gx");
-    String gy = createGroup("gy");
-    String gz = createGroup("gz");
+    String gx = createUniqueGroup();
+    String gy = createUniqueGroup();
+    String gz = createUniqueGroup();
     gApi.groups().id(gx).addGroups(gy);
     gApi.groups().id(gx).addGroups(gz);
     assertIncludes(gApi.groups().id(gx).includedGroups(), gy, gz);
@@ -660,8 +662,8 @@
 
   @Test
   public void listOneIncludeMember() throws Exception {
-    String gx = createGroup("gx");
-    String gy = createGroup("gy");
+    String gx = createUniqueGroup();
+    String gy = createUniqueGroup();
     gApi.groups().id(gx).addGroups(gy);
     assertIncludes(gApi.groups().id(gx).includedGroups(), gy);
   }
@@ -674,13 +676,13 @@
 
   @Test
   public void listEmptyGroupMembers() throws Exception {
-    String group = createGroup("empty");
+    String group = createUniqueGroup();
     assertThat(gApi.groups().id(group).members()).isEmpty();
   }
 
   @Test
   public void listNonEmptyGroupMembers() throws Exception {
-    String group = createGroup("group");
+    String group = createUniqueGroup();
     String user1 = name("user1");
     accountOperations.newAccount().username(user1).create();
     String user2 = name("user2");
@@ -692,7 +694,7 @@
 
   @Test
   public void listOneGroupMember() throws Exception {
-    String group = createGroup("group");
+    String group = createUniqueGroup();
     String user = name("user1");
     accountOperations.newAccount().username(user).create();
     gApi.groups().id(group).addMembers(user);
@@ -702,17 +704,17 @@
 
   @Test
   public void listGroupMembersRecursively() throws Exception {
-    String gx = createGroup("gx");
+    String gx = createUniqueGroup();
     String ux = name("ux");
     accountOperations.newAccount().username(ux).create();
     gApi.groups().id(gx).addMembers(ux);
 
-    String gy = createGroup("gy");
+    String gy = createUniqueGroup();
     String uy = name("uy");
     accountOperations.newAccount().username(uy).create();
     gApi.groups().id(gy).addMembers(uy);
 
-    String gz = createGroup("gz");
+    String gz = createUniqueGroup();
     String uz = name("uz");
     accountOperations.newAccount().username(uz).create();
     gApi.groups().id(gz).addMembers(uz);
@@ -725,7 +727,7 @@
 
   @Test
   public void usersSeeTheirDirectMembershipWhenListingMembersRecursively() throws Exception {
-    String group = createGroup("group");
+    String group = createUniqueGroup();
     gApi.groups().id(group).addMembers(user.username);
 
     setApiUser(user);
@@ -734,8 +736,8 @@
 
   @Test
   public void usersDoNotSeeTheirIndirectMembershipWhenListingMembersRecursively() throws Exception {
-    String group1 = createGroup("group1");
-    String group2 = createGroup("group2");
+    String group1 = createUniqueGroup();
+    String group2 = createUniqueGroup();
     gApi.groups().id(group1).addGroups(group2);
     gApi.groups().id(group2).addMembers(user.username);
 
@@ -791,7 +793,7 @@
 
   @Test
   public void getGroupsByOwner() throws Exception {
-    String parent = createGroup("test-parent");
+    String parent = createUniqueGroup();
     List<String> children =
         Arrays.asList(createGroup("test-child1", parent), createGroup("test-child2", parent));
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
index 0d5d2cd..29a5bd0 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
@@ -14,28 +14,17 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import com.google.gerrit.elasticsearch.ElasticContainer;
-import com.google.gerrit.elasticsearch.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
+
 import com.google.gerrit.elasticsearch.ElasticVersion;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
-import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Before;
 
 public class ElasticReindexIT extends AbstractReindexTests {
 
-  private static Config getConfig(ElasticVersion version) {
-    ElasticNodeInfo elasticNodeInfo;
-    ElasticContainer<?> container = ElasticContainer.createAndStart(version);
-    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
-    return cfg;
-  }
-
   @ConfigSuite.Default
   public static Config elasticsearchV2() {
     return getConfig(ElasticVersion.V2_4);
@@ -48,12 +37,12 @@
 
   @ConfigSuite.Config
   public static Config elasticsearchV6() {
-    return getConfig(ElasticVersion.V6_4);
+    return getConfig(ElasticVersion.V6_5);
   }
 
   @Override
   public void configureIndex(Injector injector) throws Exception {
-    ElasticTestUtils.createAllIndexes(injector);
+    createAllIndexes(injector);
   }
 
   @Before
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index 9d69955..1e60071 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
@@ -14,27 +14,16 @@
 
 package com.google.gerrit.acceptance.ssh;
 
-import com.google.gerrit.elasticsearch.ElasticContainer;
-import com.google.gerrit.elasticsearch.ElasticTestUtils;
-import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
+import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
+
 import com.google.gerrit.elasticsearch.ElasticVersion;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Injector;
-import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
 
 public class ElasticIndexIT extends AbstractIndexTests {
 
-  private static Config getConfig(ElasticVersion version) {
-    ElasticNodeInfo elasticNodeInfo;
-    ElasticContainer<?> container = ElasticContainer.createAndStart(version);
-    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    ElasticTestUtils.configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
-    return cfg;
-  }
-
   @ConfigSuite.Default
   public static Config elasticsearchV2() {
     return getConfig(ElasticVersion.V2_4);
@@ -47,11 +36,11 @@
 
   @ConfigSuite.Config
   public static Config elasticsearchV6() {
-    return getConfig(ElasticVersion.V6_4);
+    return getConfig(ElasticVersion.V6_5);
   }
 
   @Override
   public void configureIndex(Injector injector) throws Exception {
-    ElasticTestUtils.createAllIndexes(injector);
+    createAllIndexes(injector);
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 93e97c4..c3150f1 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -52,6 +52,8 @@
         return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.2";
       case V6_4:
         return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.3";
+      case V6_5:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.5.0";
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index b46e040..9f7b60c 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -21,6 +21,7 @@
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
 
 public final class ElasticTestUtils {
@@ -55,6 +56,16 @@
     }
   }
 
+  public static Config getConfig(ElasticVersion version) {
+    ElasticNodeInfo elasticNodeInfo;
+    ElasticContainer<?> container = ElasticContainer.createAndStart(version);
+    elasticNodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
+    String indicesPrefix = UUID.randomUUID().toString();
+    Config cfg = new Config();
+    configure(cfg, elasticNodeInfo.port, indicesPrefix, version);
+    return cfg;
+  }
+
   private ElasticTestUtils() {
     // hide default constructor
   }
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
index b8154ce..eeb4c09 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryAccountsTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_5);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
index 3445b36..7525b65 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryChangesTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_5);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
index 851b27d..e8d5683 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticV6QueryGroupsTest.java
@@ -41,7 +41,7 @@
       return;
     }
 
-    container = ElasticContainer.createAndStart(ElasticVersion.V6_4);
+    container = ElasticContainer.createAndStart(ElasticVersion.V6_5);
     nodeInfo = new ElasticNodeInfo(container.getHttpHost().getPort());
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/IntBlobTest.java b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
new file mode 100644
index 0000000..1abaa22
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/IntBlobTest.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class IntBlobTest {
+  // Note: Can't easily test GitRefUpdated behavior, since binding GitRefUpdated requires a thick
+  // stack of dependencies, and it's not just a simple interface or abstract class.
+
+  private Project.NameKey projectName;
+  private InMemoryRepository repo;
+  private TestRepository<InMemoryRepository> tr;
+  private RevWalk rw;
+
+  @Before
+  public void setUp() throws Exception {
+    projectName = new Project.NameKey("repo");
+    repo = new InMemoryRepository(new DfsRepositoryDescription(projectName.get()));
+    tr = new TestRepository<>(repo);
+    rw = tr.getRevWalk();
+  }
+
+  @Test
+  public void parseNoRef() throws Exception {
+    assertThat(IntBlob.parse(repo, "refs/nothing")).isEmpty();
+  }
+
+  @Test
+  public void parseNonBlob() throws Exception {
+    String refName = "refs/foo/master";
+    tr.branch(refName).commit().create();
+    try {
+      IntBlob.parse(repo, refName);
+      assert_().fail("Expected IncorrectObjectTypeException");
+    } catch (IncorrectObjectTypeException e) {
+      // Expected.
+    }
+  }
+
+  @Test
+  public void parseValid() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("123"));
+    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+  }
+
+  @Test
+  public void parseWithWhitespace() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob(" 123 "));
+    assertThat(IntBlob.parse(repo, refName)).value().isEqualTo(IntBlob.create(id, 123));
+  }
+
+  @Test
+  public void parseInvalid() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("1 2 3"));
+    try {
+      IntBlob.parse(repo, refName);
+      assert_().fail("Expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e).hasMessageThat().isEqualTo("invalid value in refs/foo blob at " + id.name());
+    }
+  }
+
+  @Test
+  public void tryStoreNoOldId() throws Exception {
+    String refName = "refs/foo";
+    RefUpdate ru =
+        IntBlob.tryStore(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
+  }
+
+  @Test
+  public void tryStoreOldIdZero() throws Exception {
+    String refName = "refs/foo";
+    RefUpdate ru =
+        IntBlob.tryStore(
+            repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.NEW);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(ru.getNewObjectId(), 123));
+  }
+
+  @Test
+  public void tryStoreCorrectOldId() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("123"));
+    RefUpdate ru =
+        IntBlob.tryStore(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.FORCED);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(ru.getNewObjectId(), 456));
+  }
+
+  @Test
+  public void tryStoreWrongOldId() throws Exception {
+    String refName = "refs/foo";
+    RefUpdate ru =
+        IntBlob.tryStore(
+            repo,
+            rw,
+            projectName,
+            refName,
+            ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+            123,
+            GitReferenceUpdated.DISABLED);
+    assertThat(ru.getResult()).isEqualTo(RefUpdate.Result.LOCK_FAILURE);
+    assertThat(ru.getName()).isEqualTo(refName);
+    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+  }
+
+  @Test
+  public void storeNoOldId() throws Exception {
+    String refName = "refs/foo";
+    IntBlob.store(repo, rw, projectName, refName, null, 123, GitReferenceUpdated.DISABLED);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(getRef(refName), 123));
+  }
+
+  @Test
+  public void storeOldIdZero() throws Exception {
+    String refName = "refs/foo";
+    IntBlob.store(
+        repo, rw, projectName, refName, ObjectId.zeroId(), 123, GitReferenceUpdated.DISABLED);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(getRef(refName), 123));
+  }
+
+  @Test
+  public void storeCorrectOldId() throws Exception {
+    String refName = "refs/foo";
+    ObjectId id = tr.update(refName, tr.blob("123"));
+    IntBlob.store(repo, rw, projectName, refName, id, 456, GitReferenceUpdated.DISABLED);
+    assertThat(IntBlob.parse(repo, refName))
+        .value()
+        .isEqualTo(IntBlob.create(getRef(refName), 456));
+  }
+
+  @Test
+  public void storeWrongOldId() throws Exception {
+    String refName = "refs/foo";
+    try {
+      IntBlob.store(
+          repo,
+          rw,
+          projectName,
+          refName,
+          ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+          123,
+          GitReferenceUpdated.DISABLED);
+      assert_().fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      assertThat(e.getFailedRefs()).containsExactly("refs/foo");
+    }
+    assertThat(IntBlob.parse(repo, refName)).isEmpty();
+  }
+
+  private ObjectId getRef(String refName) throws IOException {
+    return repo.exactRef(refName).getObjectId();
+  }
+}
diff --git a/plugins/BUILD b/plugins/BUILD
index 1ce0fba7..0feac10 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -42,6 +42,7 @@
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
+    "//java/com/google/gerrit/util/cli",
     "//java/com/google/gerrit/util/http",
     "//lib/commons:compress",
     "//lib/commons:dbcp",
diff --git a/plugins/download-commands b/plugins/download-commands
index cf58d79..edd7156 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit cf58d79bc034e8904aa459d8974df5796a734e1d
+Subproject commit edd715618415d9a5e03b4555d9a2d3cca8fff6e8
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index af982cf..4266b22 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -91,7 +91,7 @@
 element. An example of this is in comment threads. A diff view supports actions
 on comment threads, but there may be zero or many comment threads attached at
 any given point. So the shortcut is declared as doc-only by the diff view and
-by gr-app, and actually implemented by gr-diff-comment-thread.
+by gr-app, and actually implemented by gr-comment-thread.
 
 NOTE: doc-only shortcuts will not be customizable in the same way that other
 shortcuts are.
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 42c9e88..67fdff1 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
@@ -219,7 +219,7 @@
         [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
         [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
 
-        // Final two are actually handled by gr-diff-comment-thread.
+        // Final two are actually handled by gr-comment-thread.
         [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
         [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
       };
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
index 2201a9a..4d8e5ae 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../diff/gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
 
 <dom-module id="gr-thread-list">
   <template>
@@ -28,7 +28,7 @@
         min-height: 20rem;
         padding: 1rem;
       }
-      gr-diff-comment-thread {
+      gr-comment-thread {
         display: block;
         margin-bottom: .5rem;
         max-width: 80ch;
@@ -54,9 +54,9 @@
         display: flex;
         margin-right: 1rem;
       }
-      .draftsOnly:not(.unresolvedOnly) gr-diff-comment-thread[has-draft],
-      .unresolvedOnly:not(.draftsOnly) gr-diff-comment-thread[unresolved],
-      .draftsOnly.unresolvedOnly gr-diff-comment-thread[has-draft][unresolved] {
+      .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+      .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+      .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
         display: block
       }
     </style>
@@ -82,7 +82,7 @@
           as="thread"
           initial-count="5"
           target-framerate="60">
-        <gr-diff-comment-thread
+        <gr-comment-thread
             show-file-path
             change-num="[[changeNum]]"
             comments="[[thread.comments]]"
@@ -94,7 +94,7 @@
             path="[[thread.path]]"
             root-id="{{thread.rootId}}"
             on-thread-changed="_handleCommentsChanged"
-            on-thread-discard="_handleThreadDiscard"></gr-diff-comment-thread>
+            on-thread-discard="_handleThreadDiscard"></gr-comment-thread>
       </template>
     </div>
   </template>
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 804446a..792644e 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
@@ -171,7 +171,7 @@
       ];
       flushAsynchronousOperations();
       threadElements = Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread');
+          .querySelectorAll('gr-comment-thread');
     });
 
     teardown(() => {
@@ -188,7 +188,7 @@
 
     test('there are five threads by default', () => {
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 5);
+          .querySelectorAll('gr-comment-thread').length, 5);
     });
 
     test('_computeSortedThreads', () => {
@@ -231,14 +231,14 @@
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 3);
+          .querySelectorAll('gr-comment-thread').length, 3);
     });
 
     test('toggle drafts only shows threads with draft comments', () => {
       MockInteractions.tap(element.$.draftToggle);
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 2);
+          .querySelectorAll('gr-comment-thread').length, 2);
     });
 
     test('toggle drafts and unresolved only shows threads with drafts and ' +
@@ -247,7 +247,7 @@
       MockInteractions.tap(element.$.unresolvedToggle);
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root)
-          .querySelectorAll('gr-diff-comment-thread').length, 2);
+          .querySelectorAll('gr-comment-thread').length, 2);
     });
 
     test('modification events are consumed and displatched', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index d2731a2..6f5a8d3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -20,10 +20,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderBinary) { return; }
 
-  function GrDiffBuilderBinary(diff, commentThreadEls, prefs,
-      outputEl) {
-    GrDiffBuilder.call(this, diff, commentThreadEls, prefs,
-        outputEl);
+  function GrDiffBuilderBinary(diff, prefs, outputEl) {
+    GrDiffBuilder.call(this, diff, prefs, outputEl);
   }
 
   GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index f05f4f0..bf543e5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -22,10 +22,8 @@
 
   const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
 
-  function GrDiffBuilderImage(diff, commentThreadEls, prefs,
-      outputEl, baseImage, revisionImage) {
-    GrDiffBuilderSideBySide.call(this, diff, commentThreadEls,
-        prefs, outputEl, []);
+  function GrDiffBuilderImage(diff, prefs, outputEl, baseImage, revisionImage) {
+    GrDiffBuilderSideBySide.call(this, diff, prefs, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 81cbabb..2cf9782 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -20,10 +20,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(diff, commentThreadEls,
-      prefs, outputEl, layers) {
-    GrDiffBuilder.call(this, diff, commentThreadEls, prefs,
-        outputEl, layers);
+  function GrDiffBuilderSideBySide(diff, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
@@ -99,10 +97,6 @@
       row.appendChild(action);
     } else {
       const textEl = this._createTextEl(line, side);
-      const threadGroupEl = this._commentThreadGroupForLine(line, side);
-      if (threadGroupEl) {
-        textEl.appendChild(threadGroupEl);
-      }
       row.appendChild(textEl);
     }
   };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 2dcdee4..6020e19 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -20,10 +20,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(diff, commentThreadEls, prefs,
-      outputEl, layers) {
-    GrDiffBuilder.call(this, diff, commentThreadEls, prefs,
-        outputEl, layers);
+  function GrDiffBuilderUnified(diff, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
@@ -88,10 +86,6 @@
       row.appendChild(action);
     } else {
       const textEl = this._createTextEl(line);
-      const threadGroupEl = this._commentThreadGroupForLine(line);
-      if (threadGroupEl) {
-        textEl.appendChild(threadGroupEl);
-      }
       row.appendChild(textEl);
     }
     return row;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index aeaee9d..098a4af 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -18,7 +18,6 @@
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
 <link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
 
@@ -113,6 +112,7 @@
           /** @type {!Array<!Gerrit.HoveredRange>} */
           commentRanges: {
             type: Array,
+            value: () => [],
           },
         },
 
@@ -120,10 +120,6 @@
           return this.queryEffectiveChildren('#diffTable');
         },
 
-        get _commentThreadElements() {
-          return this.queryAllEffectiveChildren('.comment-thread');
-        },
-
         observers: [
           '_groupsChanged(_groups.splices)',
         ],
@@ -291,20 +287,16 @@
 
           let builder = null;
           if (this.isImageDiff) {
-            builder = new GrDiffBuilderImage(diff,
-              this._commentThreadElements, prefs, this.diffElement,
+            builder = new GrDiffBuilderImage(diff, prefs, this.diffElement,
               this.baseImage, this.revisionImage);
           } else if (diff.binary) {
             // If the diff is binary, but not an image.
-            return new GrDiffBuilderBinary(diff,
-                this._commentThreadElements, prefs, this.diffElement);
+            return new GrDiffBuilderBinary(diff, prefs, this.diffElement);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            builder = new GrDiffBuilderSideBySide(diff,
-                this._commentThreadElements, prefs, this.diffElement,
+            builder = new GrDiffBuilderSideBySide(diff, prefs, this.diffElement,
                 this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            builder = new GrDiffBuilderUnified(diff,
-                this._commentThreadElements, prefs, this.diffElement,
+            builder = new GrDiffBuilderUnified(diff, prefs, this.diffElement,
                 this._layers);
           }
           if (!builder) {
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 d428f68..e892605 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
@@ -20,60 +20,6 @@
   // Prevent redefinition.
   if (window.GrDiffBuilder) { return; }
 
-  /** @enum {string} */
-  Gerrit.DiffSide = {
-    LEFT: 'left',
-    RIGHT: 'right',
-    BOTH: 'both',
-  };
-
-  /**
-   * @param {!Array<!HTMLElement>} threadEls
-   * @param {!{beforeNumber: (number|string|undefined),
-   *           afterNumber: (number|string|undefined)}}
-   *     lineInfo
-   * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT, BOTH) for
-   *     which to return the threads (default: BOTH).
-   * @return {!Array<!HTMLElement>} The thread elements matching the given
-   *     location.
-   */
-  Gerrit.filterThreadElsForLocation = function(
-      threadEls, lineInfo, side = Gerrit.DiffSide.BOTH) {
-    function matchesLeftLine(threadEl) {
-      return threadEl.getAttribute('comment-side') ==
-          Gerrit.DiffSide.LEFT &&
-          threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
-    }
-    function matchesRightLine(threadEl) {
-      return threadEl.getAttribute('comment-side') ==
-          Gerrit.DiffSide.RIGHT &&
-          threadEl.getAttribute('line-num') == lineInfo.afterNumber;
-    }
-    function matchesFileComment(threadEl) {
-      return (side === Gerrit.DiffSide.BOTH ||
-              threadEl.getAttribute('comment-side') == side) &&
-            // line/range comments have 1-based line set, if line is falsy it's
-            // a file comment
-            !threadEl.getAttribute('line-num');
-    }
-
-    // Select the appropriate matchers for the desired side and line
-    // If side is BOTH, we want both the left and right matcher.
-    const matchers = [];
-    if (side !== Gerrit.DiffSide.RIGHT) {
-      matchers.push(matchesLeftLine);
-    }
-    if (side !== Gerrit.DiffSide.LEFT) {
-      matchers.push(matchesRightLine);
-    }
-    if (lineInfo.afterNumber === 'FILE' ||
-        lineInfo.beforeNumber === 'FILE') {
-      matchers.push(matchesFileComment);
-    }
-    return threadEls.filter(threadEl =>
-        matchers.some(matcher => matcher(threadEl)));
-  };
-
   /**
    * In JS, unicode code points above 0xFFFF occupy two elements of a string.
    * For example '𐀏'.length is 2. An occurence of such a code point is called a
@@ -96,8 +42,7 @@
    */
   const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, commentThreadEls, prefs,
-      outputEl, layers) {
+  function GrDiffBuilder(diff, prefs, outputEl, layers) {
     this._diff = diff;
     this._prefs = prefs;
     this._outputEl = outputEl;
@@ -119,8 +64,6 @@
         layer.addListener(this._handleLayerUpdate.bind(this));
       }
     }
-
-    this._threadEls = commentThreadEls;
   }
 
   GrDiffBuilder.GroupType = {
@@ -373,31 +316,6 @@
     return button;
   };
 
-  /**
-   * @param {!GrDiffLine} line
-   * @param {!GrDiffBuilder.Side=} side The side (LEFT, RIGHT, BOTH) for which
-   *     to return the thread group (default: BOTH).
-   * @return {!Object}
-   */
-  GrDiffBuilder.prototype._commentThreadGroupForLine = function(
-      line, commentSide = GrDiffBuilder.Side.BOTH) {
-    const threadElsForGroup =
-        Gerrit.filterThreadElsForLocation(this._threadEls, line, commentSide);
-    if (!threadElsForGroup || threadElsForGroup.length === 0) {
-      return null;
-    }
-
-    const threadGroupEl = document.createElement('div');
-    threadGroupEl.className = 'thread-group';
-    for (const threadEl of threadElsForGroup) {
-      Polymer.dom(threadGroupEl).appendChild(threadEl);
-    }
-    if (commentSide) {
-      threadGroupEl.setAttribute('data-side', commentSide);
-    }
-    return threadGroupEl;
-  };
-
   GrDiffBuilder.prototype._createLineEl = function(
       line, number, type, opt_class) {
     const td = this._createElement('td');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index c7a1140..1b0ba04 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -75,70 +75,11 @@
         show_tabs: true,
         tab_size: 4,
       };
-      builder = new GrDiffBuilder({content: []}, [], prefs);
+      builder = new GrDiffBuilder({content: []}, prefs);
     });
 
     teardown(() => { sandbox.restore(); });
 
-    test('filterThreadElsForLocation with no threads', () => {
-      const line = {beforeNumber: 3, afterNumber: 5};
-
-      const threads = [];
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line), []);
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line,
-          Gerrit.DiffSide.LEFT), []);
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line,
-          Gerrit.DiffSide.RIGHT), []);
-    });
-
-    test('filterThreadElsForLocation for line comments', () => {
-      const line = {beforeNumber: 3, afterNumber: 5};
-
-      const l3 = document.createElement('div');
-      l3.setAttribute('line-num', 3);
-      l3.setAttribute('comment-side', 'left');
-
-      const l5 = document.createElement('div');
-      l5.setAttribute('line-num', 5);
-      l5.setAttribute('comment-side', 'left');
-
-      const r3 = document.createElement('div');
-      r3.setAttribute('line-num', 3);
-      r3.setAttribute('comment-side', 'right');
-
-      const r5 = document.createElement('div');
-      r5.setAttribute('line-num', 5);
-      r5.setAttribute('comment-side', 'right');
-
-      const threadEls = [l3, l5, r3, r5];
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line),
-          [l3, r5]);
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.LEFT), [l3]);
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.RIGHT), [r5]);
-    });
-
-    test('filterThreadElsForLocation for file comments', () => {
-      const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-      const l = document.createElement('div');
-      l.setAttribute('comment-side', 'left');
-
-      const r = document.createElement('div');
-      r.setAttribute('comment-side', 'right');
-
-      const threadEls = [l, r];
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line),
-          [l, r]);
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.BOTH), [l, r]);
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.LEFT), [l]);
-      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
-          Gerrit.DiffSide.RIGHT), [r]);
-    });
-
     test('_createElement classStr applies all classes', () => {
       const node = builder._createElement('div', 'test classes');
       assert.isTrue(node.classList.contains('gr-diff'));
@@ -312,73 +253,6 @@
       }
     });
 
-    test('comment thread group creation', () => {
-      const l3 = document.createElement('div');
-      l3.className = 'comment-thread';
-      l3.setAttribute('comment-side', 'left');
-      l3.setAttribute('line-num', 3);
-
-      const l5 = document.createElement('div');
-      l5.className = 'comment-thread';
-      l5.setAttribute('comment-side', 'left');
-      l5.setAttribute('line-num', 5);
-
-      const r5 = document.createElement('div');
-      r5.className = 'comment-thread';
-      r5.setAttribute('comment-side', 'right');
-      r5.setAttribute('line-num', 5);
-
-      builder = new GrDiffBuilder({content: []}, [l3, l5, r5], prefs);
-
-      function checkThreadGroupProps(threadGroupEl,
-          expectedThreadEls) {
-        const threadEls = Polymer.dom(threadGroupEl).queryDistributedElements(
-            '.comment-thread');
-        assert.equal(threadEls.length, expectedThreadEls.length);
-        for (let i=0; i<expectedThreadEls.length; i++) {
-          assert.equal(threadEls[i], expectedThreadEls[i]);
-        }
-      }
-
-      let line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = 5;
-      line.afterNumber = 5;
-      let threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, [l5, r5]);
-
-      threadGroupEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      checkThreadGroupProps(threadGroupEl, [r5]);
-
-      threadGroupEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      checkThreadGroupProps(threadGroupEl, [l5]);
-
-      threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, [l5, r5]);
-
-      threadEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      checkThreadGroupProps(threadEl, [l5]);
-
-      threadGroupEl =
-          builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      checkThreadGroupProps(threadGroupEl, [r5]);
-
-      line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.beforeNumber = 5;
-      line.afterNumber = 5;
-      threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, [l5, r5]);
-
-      line = new GrDiffLine(GrDiffLine.Type.ADD);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-      threadGroupEl = builder._commentThreadGroupForLine(line);
-      checkThreadGroupProps(threadGroupEl, [l3, r5]);
-    });
-
-
     test('_handlePreferenceError called with invalid preference', () => {
       sandbox.stub(element, '_handlePreferenceError');
       const prefs = {tab_size: 0};
@@ -913,7 +787,7 @@
         outputEl = element.queryEffectiveChildren('#diffTable');
         keyLocations = {left: {}, right: {}};
         sandbox.stub(element, '_getDiffBuilder', () => {
-          const builder = new GrDiffBuilder({content}, [], prefs, outputEl);
+          const builder = new GrDiffBuilder({content}, prefs, outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             const section = document.createElement('stub');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index aee7a62..860d900 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -293,7 +293,7 @@
     },
 
     _rowHasThread(row) {
-      return row.querySelector('gr-diff-comment-thread');
+      return row.querySelector('.comment-thread');
     },
 
     /**
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 85ba202..ff00383 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
@@ -37,8 +37,8 @@
     },
 
     listeners: {
-      'comment-mouse-out': '_handleCommentMouseOut',
-      'comment-mouse-over': '_handleCommentMouseOver',
+      'comment-thread-mouseleave': '_handleCommentThreadMouseleave',
+      'comment-thread-mouseenter': '_handleCommentThreadMouseenter',
       'create-range-comment': '_createRangeComment',
     },
 
@@ -74,8 +74,16 @@
       this.debounce('selectionChange', this._handleSelection, 200);
     },
 
-    _handleCommentMouseOver(e) {
-      const threadEl = Polymer.dom(e).localTarget;
+    _getThreadEl(e) {
+      const path = Polymer.dom(e).path || [];
+      for (const pathEl of path) {
+        if (pathEl.classList.contains('comment-thread')) return pathEl;
+      }
+      return null;
+    },
+
+    _handleCommentThreadMouseenter(e) {
+      const threadEl = this._getThreadEl(e);
       const index = this._indexForThreadEl(threadEl);
 
       if (index !== undefined) {
@@ -83,8 +91,8 @@
       }
     },
 
-    _handleCommentMouseOut(e) {
-      const threadEl = Polymer.dom(e).localTarget;
+    _handleCommentThreadMouseleave(e) {
+      const threadEl = this._getThreadEl(e);
       const index = this._indexForThreadEl(threadEl);
 
       if (index !== undefined) {
@@ -248,7 +256,7 @@
         node = contentText;
         column = 0;
       } else {
-        const thread = contentTd.querySelector('gr-diff-comment-thread');
+        const thread = contentTd.querySelector('.comment-thread');
         if (thread && thread.contains(node)) {
           column = this._getLength(contentText);
           node = contentText;
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 23de407..8e3c7b0 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
@@ -72,9 +72,9 @@
           <tr class="diff-row side-by-side" left-type="remove" right-type="add">
             <td class="left lineNum" data-value="140"></td>
             <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div><gr-diff-comment-thread>
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
                 [Yet another random diff thread content here]
-              </gr-diff-comment-thread></td>
+            </div></td>
             <td class="right lineNum" data-value="120"></td>
             <!-- Next tag is formatted to eliminate zero-length text nodes. -->
             <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
@@ -197,22 +197,60 @@
         element._cachedDiffBuilder = builder;
       });
 
-      test('comment-mouse-over from line comments is ignored', () => {
+      test('comment-thread-mouseenter from line comments is ignored', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        element.appendChild(threadEl);
+        element.commentRanges = [{side: 'right'}];
+
         sandbox.stub(element, 'set');
-        element.fire('comment-mouse-over', {comment: {}});
+        threadEl.dispatchEvent(
+            new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
         assert.isFalse(element.set.called);
       });
 
-      test('comment-mouse-over from ranged comment causes set', () => {
+      test('comment-thread-mouseenter from ranged comment causes set', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        threadEl.setAttribute('range', JSON.stringify({
+          start_line: 3,
+          start_character: 4,
+          end_line: 5,
+          end_character: 6,
+        }));
+        element.appendChild(threadEl);
+        element.commentRanges = [{side: 'right', range: {
+          start_line: 3,
+          start_character: 4,
+          end_line: 5,
+          end_character: 6,
+        }}];
+
         sandbox.stub(element, 'set');
-        sandbox.stub(element, '_indexForThreadEl').returns(0);
-        element.fire('comment-mouse-over', {comment: {range: {}}});
+        threadEl.dispatchEvent(
+            new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
         assert.isTrue(element.set.called);
+        const args = element.set.lastCall.args;
+        assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
+        assert.deepEqual(args[1], true);
       });
 
-      test('comment-mouse-out from line comments is ignored', () => {
-        element.fire('comment-mouse-over', {comment: {}});
-        assert.isFalse(builder.getContentsByLineRange.called);
+      test('comment-thread-mouseleave from line comments is ignored', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        element.appendChild(threadEl);
+        element.commentRanges = [{side: 'right'}];
+
+        sandbox.stub(element, 'set');
+        threadEl.dispatchEvent(
+            new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
+        assert.isFalse(element.set.called);
       });
 
       test('on create-range-comment action box is removed', () => {
@@ -465,7 +503,7 @@
       test('starts in comment thread element', () => {
         const startContent = stubContent(140, 'left');
         const comment = startContent.parentElement.querySelector(
-            'gr-diff-comment-thread');
+            '.comment-thread');
         const endContent = stubContent(141, 'left');
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
         assert.isTrue(element.isRangeSelected());
@@ -481,7 +519,7 @@
       test('ends in comment thread element', () => {
         const content = stubContent(140, 'left');
         const comment = content.parentElement.querySelector(
-            'gr-diff-comment-thread');
+            '.comment-thread');
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
index d335e7a..4c310b9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -19,7 +19,7 @@
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../../shared/gr-comment-thread/gr-comment-thread.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
 
 <dom-module id="gr-diff-host">
@@ -37,7 +37,6 @@
         commit-range="[[commitRange]]"
         hidden$="[[hidden]]"
         no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
-        comments="[[comments]]"
         line-wrapping="[[lineWrapping]]"
         view-mode="[[viewMode]]"
         line-of-interest="[[lineOfInterest]]"
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 814c7268..8b9d8066 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
@@ -63,6 +63,12 @@
         a.end_character === b.end_character;
   }
 
+  /** @enum {string} */
+  Gerrit.DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
   /**
    * Wrapper around gr-diff.
    *
@@ -196,11 +202,6 @@
         type: Number,
         computed: '_computeParentIndex(patchRange.*)',
       },
-
-      _threadEls: {
-        type: Array,
-        value: [],
-      },
     },
 
     behaviors: [
@@ -208,7 +209,16 @@
     ],
 
     listeners: {
+      // These are named inconsistently for a reason:
+      // The create-comment event is fired to indicate that we should
+      // create a comment.
+      // The comment-* events are just notifying that the comments did already
+      // change in some way, and that we should update any models we may want
+      // to keep in sync.
       'create-comment': '_handleCreateComment',
+      'comment-discard': '_handleCommentDiscard',
+      'comment-update': '_handleCommentUpdate',
+      'comment-save': '_handleCommentSave',
     },
 
     observers: [
@@ -335,7 +345,7 @@
      * @return {!Array<!HTMLElement>}
      */
     getThreadEls() {
-      return this._threadEls;
+      return Polymer.dom(this.$.diff).querySelectorAll('.comment-thread');
     },
 
     /** @param {HTMLElement} el */
@@ -462,7 +472,6 @@
       return isImageDiff(diff);
     },
 
-
     _commentsChanged(newComments) {
       const allComments = [];
       for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
@@ -581,21 +590,20 @@
     },
 
     _attachThreadElement(threadEl) {
-      this._threadEls.push(threadEl);
       Polymer.dom(this.$.diff).appendChild(threadEl);
     },
 
     _clearThreads() {
-      for (const threadEl of this._threadEls) {
+      for (const threadEl of this.getThreadEls()) {
         const parent = Polymer.dom(threadEl).parentNode;
         Polymer.dom(parent).removeChild(threadEl);
       }
-      this._threadEls = [];
     },
 
     _createThreadElement(thread) {
-      const threadEl = document.createElement('gr-diff-comment-thread');
+      const threadEl = document.createElement('gr-comment-thread');
       threadEl.className = 'comment-thread';
+      threadEl.slot = `${thread.commentSide}-${thread.lineNum}`;
       threadEl.comments = thread.comments;
       threadEl.commentSide = thread.commentSide;
       threadEl.isOnParent = !!thread.isOnParent;
@@ -616,10 +624,6 @@
         const parent = Polymer.dom(threadEl).parentNode;
         Polymer.dom(parent).removeChild(threadEl);
 
-        const i = this._threadEls.findIndex(
-            threadEl => threadEl.rootId == e.detail.rootId);
-        this._threadEls.splice(i, 1);
-
         threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
         threadEl.removeEventListener('thread-discard', threadDiscardListener);
       };
@@ -651,12 +655,57 @@
         return rangesEqual(threadRange, range);
       }
 
-      const filteredThreadEls = Gerrit.filterThreadElsForLocation(
-          this._threadEls, line, commentSide).filter(matchesRange);
+      const filteredThreadEls = this._filterThreadElsForLocation(
+          this.getThreadEls(), line, commentSide).filter(matchesRange);
       return filteredThreadEls.length ? filteredThreadEls[0] : null;
     },
 
     /**
+     * @param {!Array<!HTMLElement>} threadEls
+     * @param {!{beforeNumber: (number|string|undefined|null),
+     *           afterNumber: (number|string|undefined|null)}}
+     *     lineInfo
+     * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT) for
+     *     which to return the threads.
+     * @return {!Array<!HTMLElement>} The thread elements matching the given
+     *     location.
+     */
+    _filterThreadElsForLocation(threadEls, lineInfo, side) {
+      function matchesLeftLine(threadEl) {
+        return threadEl.getAttribute('comment-side') ==
+            Gerrit.DiffSide.LEFT &&
+            threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
+      }
+      function matchesRightLine(threadEl) {
+        return threadEl.getAttribute('comment-side') ==
+            Gerrit.DiffSide.RIGHT &&
+            threadEl.getAttribute('line-num') == lineInfo.afterNumber;
+      }
+      function matchesFileComment(threadEl) {
+        return threadEl.getAttribute('comment-side') == side &&
+              // line/range comments have 1-based line set, if line is falsy it's
+              // a file comment
+              !threadEl.getAttribute('line-num');
+      }
+
+      // Select the appropriate matchers for the desired side and line
+      // If side is BOTH, we want both the left and right matcher.
+      const matchers = [];
+      if (side !== Gerrit.DiffSide.RIGHT) {
+        matchers.push(matchesLeftLine);
+      }
+      if (side !== Gerrit.DiffSide.LEFT) {
+        matchers.push(matchesRightLine);
+      }
+      if (lineInfo.afterNumber === 'FILE' ||
+          lineInfo.beforeNumber === 'FILE') {
+        matchers.push(matchesFileComment);
+      }
+      return threadEls.filter(threadEl =>
+          matchers.some(matcher => matcher(threadEl)));
+    },
+
+    /**
      * Take a diff that was loaded with a ignore-whitespace other than
      * IGNORE_NONE, and convert delta chunks labeled as common into shared
      * chunks.
@@ -723,5 +772,76 @@
           this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
     },
 
+    _handleCommentSave(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      const idx = this._findDraftIndex(comment, side);
+      this.set(['comments', side, idx], comment);
+      this._handleCommentSaveOrDiscard();
+    },
+
+    _handleCommentDiscard(e) {
+      const comment = e.detail.comment;
+      this._removeComment(comment);
+      this._handleCommentSaveOrDiscard();
+    },
+
+    /**
+     * Closure annotation for Polymer.prototype.push is off. Submitted PR:
+     * https://github.com/Polymer/polymer/pull/4776
+     * but for not supressing annotations.
+     *
+     * @suppress {checkTypes}
+     */
+    _handleCommentUpdate(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      let idx = this._findCommentIndex(comment, side);
+      if (idx === -1) {
+        idx = this._findDraftIndex(comment, side);
+      }
+      if (idx !== -1) { // Update draft or comment.
+        this.set(['comments', side, idx], comment);
+      } else { // Create new draft.
+        this.push(['comments', side], comment);
+      }
+    },
+
+    _handleCommentSaveOrDiscard() {
+      this.dispatchEvent(new CustomEvent('diff-comments-modified',
+          {bubbles: true}));
+    },
+
+    _removeComment(comment) {
+      const side = comment.__commentSide;
+      this._removeCommentFromSide(comment, side);
+    },
+
+    _removeCommentFromSide(comment, side) {
+      let idx = this._findCommentIndex(comment, side);
+      if (idx === -1) {
+        idx = this._findDraftIndex(comment, side);
+      }
+      if (idx !== -1) {
+        this.splice('comments.' + side, idx, 1);
+      }
+    },
+
+    /** @return {number} */
+    _findCommentIndex(comment, side) {
+      if (!comment.id || !this.comments[side]) {
+        return -1;
+      }
+      return this.comments[side].findIndex(item => item.id === comment.id);
+    },
+
+    /** @return {number} */
+    _findDraftIndex(comment, side) {
+      if (!comment.__draftID || !this.comments[side]) {
+        return -1;
+      }
+      return this.comments[side].findIndex(
+          item => item.__draftID === comment.__draftID);
+    },
   });
 })();
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 423bdc6..ab9daec 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
@@ -46,15 +46,195 @@
         async getLoggedIn() { return getLoggedIn; },
       });
       element = fixture('basic');
-      // For reasons beyond me, fixture reuses elements, cleans out some
-      // stuff but not that list.
-      element._threadEls = [];
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
+    suite('handle comment-update', () => {
+      setup(() => {
+        sandbox.stub(element, '_commentsChanged');
+        element.comments = {
+          meta: {
+            changeNum: '42',
+            patchRange: {
+              basePatchNum: 'PARENT',
+              patchNum: 3,
+            },
+            path: '/path/to/foo',
+            projectConfig: {foo: 'bar'},
+          },
+          left: [
+            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          ],
+          right: [
+            {id: 'c1', __commentSide: 'right'},
+            {id: 'c2', __commentSide: 'right'},
+            {id: 'd1', __draft: true, __commentSide: 'right'},
+            {id: 'd2', __draft: true, __commentSide: 'right'},
+          ],
+        };
+      });
+
+      test('creating a draft', () => {
+        const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+          __commentSide: 'left'};
+        element.fire('comment-update', {comment});
+        assert.include(element.comments.left, comment);
+      });
+
+      test('discarding a draft', () => {
+        const draftID = 'tempID';
+        const id = 'savedID';
+        const comment = {
+          __draft: true,
+          __draftID: draftID,
+          side: 'PARENT',
+          __commentSide: 'left',
+        };
+        const diffCommentsModifiedStub = sandbox.stub();
+        element.addEventListener('diff-comments-modified',
+            diffCommentsModifiedStub);
+        element.comments.left.push(comment);
+        comment.id = id;
+        element.fire('comment-discard', {comment});
+        const drafts = element.comments.left.filter(item => {
+          return item.__draftID === draftID;
+        });
+        assert.equal(drafts.length, 0);
+        assert.isTrue(diffCommentsModifiedStub.called);
+      });
+
+      test('saving a draft', () => {
+        const draftID = 'tempID';
+        const id = 'savedID';
+        const comment = {
+          __draft: true,
+          __draftID: draftID,
+          side: 'PARENT',
+          __commentSide: 'left',
+        };
+        const diffCommentsModifiedStub = sandbox.stub();
+        element.addEventListener('diff-comments-modified',
+            diffCommentsModifiedStub);
+        element.comments.left.push(comment);
+        comment.id = id;
+        element.fire('comment-save', {comment});
+        const drafts = element.comments.left.filter(item => {
+          return item.__draftID === draftID;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].id, id);
+        assert.isTrue(diffCommentsModifiedStub.called);
+      });
+    });
+
+    test('remove comment', () => {
+      sandbox.stub(element, '_commentsChanged');
+      element.comments = {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+          {id: 'd2', __draft: true, __commentSide: 'right'},
+        ],
+      };
+
+      element._removeComment({});
+      // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
+      // to believe that one object deepEquals another even when they do :-/.
+      assert.equal(JSON.stringify(element.comments), JSON.stringify({
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+          {id: 'd2', __draft: true, __commentSide: 'right'},
+        ],
+      }));
+
+      element._removeComment({id: 'bc2', side: 'PARENT',
+        __commentSide: 'left'});
+      assert.deepEqual(element.comments, {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+          {id: 'd2', __draft: true, __commentSide: 'right'},
+        ],
+      });
+
+      element._removeComment({id: 'd2', __commentSide: 'right'});
+      assert.deepEqual(element.comments, {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+        ],
+      });
+    });
+
     test('thread-discard handling', () => {
       const threads = [
         {comments: [{id: 4711}]},
@@ -76,7 +256,7 @@
       threadEls[0].dispatchEvent(
           new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
       const attachedThreads = element.queryAllEffectiveChildren(
-          'gr-diff-comment-thread');
+          'gr-comment-thread');
       assert.equal(attachedThreads.length, 1);
       assert.equal(attachedThreads[0].rootId, 42);
     });
@@ -589,10 +769,11 @@
       });
     });
 
-    test('getThreadEls() returns _threadEls', () => {
-      const returnValue = [document.createElement('b')];
-      element._threadEls = returnValue;
-      assert.equal(element.getThreadEls(), returnValue);
+    test('getThreadEls() returns .comment-threads', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      Polymer.dom(element.$.diff).appendChild(threadEl);
+      assert.deepEqual(element.getThreadEls(), [threadEl]);
     });
 
     test('delegates addDraftAtLine(el)', () => {
@@ -685,12 +866,6 @@
       assert.equal(element.$.diff.noRenderOnPrefsChange, value);
     });
 
-    test('passes in comments', () => {
-      const value = {left: [], right: []};
-      element.comments = value;
-      assert.equal(element.$.diff.comments, value);
-    });
-
     test('passes in lineWrapping', () => {
       const value = true;
       element.lineWrapping = value;
@@ -955,7 +1130,7 @@
           commentSide, undefined, false));
 
       let threads = Polymer.dom(element.$.diff)
-          .queryDistributedElements('gr-diff-comment-thread');
+          .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
       assert.equal(threads[0].commentSide, commentSide);
@@ -976,7 +1151,7 @@
           '3', 1, commentSide, range, true));
 
       threads = Polymer.dom(element.$.diff)
-          .queryDistributedElements('gr-diff-comment-thread');
+          .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 2);
       assert.equal(threads[1].commentSide, commentSide);
@@ -985,6 +1160,65 @@
       assert.equal(threads[1].patchNum, 3);
     });
 
+    test('_filterThreadElsForLocation with no threads', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
+
+      const threads = [];
+      assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
+      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.LEFT), []);
+      assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.RIGHT), []);
+    });
+
+    test('_filterThreadElsForLocation for line comments', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
+
+      const l3 = document.createElement('div');
+      l3.setAttribute('line-num', 3);
+      l3.setAttribute('comment-side', 'left');
+
+      const l5 = document.createElement('div');
+      l5.setAttribute('line-num', 5);
+      l5.setAttribute('comment-side', 'left');
+
+      const r3 = document.createElement('div');
+      r3.setAttribute('line-num', 3);
+      r3.setAttribute('comment-side', 'right');
+
+      const r5 = document.createElement('div');
+      r5.setAttribute('line-num', 5);
+      r5.setAttribute('comment-side', 'right');
+
+      const threadEls = [l3, l5, r3, r5];
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+          [l3, r5]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l3]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r5]);
+    });
+
+    test('_filterThreadElsForLocation for file comments', () => {
+      const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+      const l = document.createElement('div');
+      l.setAttribute('comment-side', 'left');
+
+      const r = document.createElement('div');
+      r.setAttribute('comment-side', 'right');
+
+      const threadEls = [l, r];
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+          [l, r]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.BOTH), [l, r]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l]);
+      assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r]);
+    });
+
     suite('_translateChunksToIgnore', () => {
       let content;
 
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 27e467d..6a9d88f 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
@@ -83,7 +83,7 @@
         targetClasses.push(SelectionClass.BLAME);
       } else {
         const commentSelected =
-            this._elementDescendedFromClass(e.target, 'gr-diff-comment');
+            this._elementDescendedFromClass(e.target, 'gr-comment');
         const side = this.diffBuilder.getSideByLineEl(lineEl);
 
         targetClasses.push(side === 'left' ?
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index f34429b8..469a894 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -36,7 +36,7 @@
           <td class="content">
             <div class="contentText" data-side="left">ba ba</div>
             <div data-side="left">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <div class="gr-formatted-text message">
                   <span id="output" class="gr-linked-text">This is a comment</span>
                 </div>
@@ -58,7 +58,7 @@
           <td class="content">
             <div class="contentText" data-side="right">more more more</div>
             <div data-side="right">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <div class="gr-formatted-text message">
                   <span id="output" class="gr-linked-text">This is a comment on the right</span>
                 </div>
@@ -72,7 +72,7 @@
           <td class="content">
             <div class="contentText" data-side="left">ga ga</div>
             <div data-side="left">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <div class="gr-formatted-text message">
                   <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
                 </div>
@@ -87,7 +87,7 @@
           <td class="content">
             <div class="contentText" data-side="left">ga ga</div>
             <div data-side="left">
-              <div class="gr-diff-comment-thread">
+              <div class="comment-thread">
                 <textarea data-side="right">test for textarea copying</textarea>
               </div>
             </div>
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 5a56069..dadf8a7 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
@@ -221,7 +221,7 @@
         [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
         [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
 
-        // Final two are actually handled by gr-diff-comment-thread.
+        // Final two are actually handled by gr-comment-thread.
         [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
         [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
       };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index e587953..4ccdf96 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -294,7 +294,6 @@
               is-image-diff="[[isImageDiff]]"
               base-image="[[baseImage]]"
               revision-image="[[revisionImage]]">
-            <slot></slot>
             <table
                 id="diffTable"
                 class$="[[_diffTableClass]]"
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 d5f92f1..996d484 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -60,6 +60,20 @@
         node.classList.contains('comment-thread');
   }
 
+  /**
+   * Turn a slot element into the corresponding content element.
+   * Slots are only fully supported in Polymer 2 - in Polymer 1, they are
+   * replaced with content elements during template parsing. This conversion is
+   * not applied for imperatively created slot elements, so this method
+   * implements the same behavior as the template parsing for imperative slots.
+   */
+  Gerrit.slotToContent = function(slot) {
+    const content = document.createElement('content');
+    content.name = slot.name;
+    content.setAttribute('select', `[slot='${slot.name}']`);
+    return content;
+  };
+
   Polymer({
     is: 'gr-diff',
 
@@ -74,12 +88,6 @@
      * @event show-auth-required
      */
 
-    /**
-     * Fired when a comment is saved or discarded
-     *
-     * @event diff-comments-modified
-     */
-
      /**
       * Fired when a comment is created
       *
@@ -113,14 +121,10 @@
         reflectToAttribute: true,
       },
       noRenderOnPrefsChange: Boolean,
-      comments: {
-        type: Object,
-        value: {left: [], right: []},
-      },
       /** @type {!Array<!Gerrit.HoveredRange>} */
       _commentRanges: {
         type: Array,
-        value: [],
+        value: () => [],
       },
       lineWrapping: {
         type: Boolean,
@@ -136,6 +140,21 @@
        /** @type ?Defs.LineOfInterest */
       lineOfInterest: Object,
 
+      /**
+       * The key locations based on the comments and line of interests,
+       * where lines should not be collapsed.
+       *
+       * @type {{left: Object<(string|number), number>,
+       *     right: Object<(string|number), number>}}
+       */
+      _keyLocations: {
+        type: Object,
+        value: () => ({
+          left: {},
+          right: {},
+        }),
+      },
+
       loading: {
         type: Boolean,
         value: false,
@@ -222,15 +241,12 @@
     ],
 
     listeners: {
-      'comment-discard': '_handleCommentDiscard',
-      'comment-update': '_handleCommentUpdate',
-      'comment-save': '_handleCommentSave',
       'create-range-comment': '_handleCreateRangeComment',
       'render-content': '_handleRenderContent',
     },
 
     attached() {
-      this._updateRangesWhenNodesChange();
+      this._observeNodes();
     },
 
     detached() {
@@ -238,25 +254,53 @@
       this._unobserveNodes();
     },
 
-    _updateRangesWhenNodesChange() {
+    _observeNodes() {
+      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        // In principle we should also handle removed nodes, but I have not
+        // figured out how to do that yet without also catching all the removals
+        // caused by further redistribution. Right now, comments are never
+        // removed by no longer slotting them in, so I decided to not handle
+        // this situation until it occurs.
+        this._updateRanges(addedThreadEls);
+        this._updateKeyLocations(addedThreadEls);
+        this._redispatchHoverEvents(addedThreadEls);
+      });
+    },
+
+    _updateRanges(addedThreadEls) {
       function commentRangeFromThreadEl(threadEl) {
         const side = threadEl.getAttribute('comment-side');
         const range = JSON.parse(threadEl.getAttribute('range'));
         return {side, range, hovering: false};
       }
 
-      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
-        const addedThreadEls = info.addedNodes.filter(isThreadEl);
-        const addedCommentRanges = addedThreadEls
-            .map(commentRangeFromThreadEl)
-            .filter(({range}) => range);
-        this.push('_commentRanges', ...addedCommentRanges);
-        // In principal we should also handle removed nodes, but I have not
-        // figured out how to do that yet without also catching all the removals
-        // caused by further redistribution. Right now, comments are never
-        // removed by no longer slotting them in, so I decided to not handle
-        // this situation until it occurs.
-      });
+      const addedCommentRanges = addedThreadEls
+          .map(commentRangeFromThreadEl)
+          .filter(({range}) => range);
+      this.push('_commentRanges', ...addedCommentRanges);
+    },
+
+    _updateKeyLocations(addedThreadEls) {
+      for (const threadEl of addedThreadEls) {
+        const commentSide = threadEl.getAttribute('comment-side');
+        const lineNum = threadEl.getAttribute('line-num') || GrDiffLine.FILE;
+        this._keyLocations[commentSide][lineNum] = true;
+      }
+    },
+
+    // Dispatch events that are handled by the gr-diff-highlight.
+    _redispatchHoverEvents(addedThreadEls) {
+      for (const threadEl of addedThreadEls) {
+        threadEl.addEventListener('mouseenter', () => {
+          threadEl.dispatchEvent(
+              new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
+        });
+        threadEl.addEventListener('mouseleave', () => {
+          threadEl.dispatchEvent(
+              new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
+        });
+      }
     },
 
     /** Cancel any remaining diff builder rendering work. */
@@ -291,11 +335,6 @@
       }
     },
 
-    _handleCommentSaveOrDiscard() {
-      this.dispatchEvent(new CustomEvent('diff-comments-modified',
-          {bubbles: true}));
-    },
-
     /** @return {string} */
     _computeContainerClass(loggedIn, viewMode, displayLine) {
       const classes = ['diffContainer'];
@@ -430,14 +469,16 @@
      * Gets or creates a comment thread group for a specific line and side on a
      * diff.
      * @param {!Object} contentEl
+     * @param {!Gerrit.DiffSide} commentSide
      * @return {!Node}
      */
-    _getOrCreateThreadGroup(contentEl) {
+    _getOrCreateThreadGroup(contentEl, commentSide) {
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
         threadGroupEl = document.createElement('div');
         threadGroupEl.className = 'thread-group';
+        threadGroupEl.setAttribute('data-side', commentSide);
         contentEl.appendChild(threadGroupEl);
       }
       return threadGroupEl;
@@ -490,75 +531,6 @@
       return side;
     },
 
-    _handleCommentDiscard(e) {
-      const comment = e.detail.comment;
-      this._removeComment(comment);
-      this._handleCommentSaveOrDiscard();
-    },
-
-    _removeComment(comment) {
-      const side = comment.__commentSide;
-      this._removeCommentFromSide(comment, side);
-    },
-
-    _handleCommentSave(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      const idx = this._findDraftIndex(comment, side);
-      this.set(['comments', side, idx], comment);
-      this._handleCommentSaveOrDiscard();
-    },
-
-    /**
-     * Closure annotation for Polymer.prototype.push is off. Submitted PR:
-     * https://github.com/Polymer/polymer/pull/4776
-     * but for not supressing annotations.
-     *
-     * @suppress {checkTypes} */
-    _handleCommentUpdate(e) {
-      const comment = e.detail.comment;
-      const side = e.detail.comment.__commentSide;
-      let idx = this._findCommentIndex(comment, side);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) { // Update draft or comment.
-        this.set(['comments', side, idx], comment);
-      } else { // Create new draft.
-        this.push(['comments', side], comment);
-      }
-    },
-
-    _removeCommentFromSide(comment, side) {
-      let idx = this._findCommentIndex(comment, side);
-      if (idx === -1) {
-        idx = this._findDraftIndex(comment, side);
-      }
-      if (idx !== -1) {
-        this.splice('comments.' + side, idx, 1);
-      }
-    },
-
-    /** @return {number} */
-    _findCommentIndex(comment, side) {
-      if (!comment.id || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(item => {
-        return item.id === comment.id;
-      });
-    },
-
-    /** @return {number} */
-    _findDraftIndex(comment, side) {
-      if (!comment.__draftID || !this.comments[side]) {
-        return -1;
-      }
-      return this.comments[side].findIndex(item => {
-        return item.__draftID === comment.__draftID;
-      });
-    },
-
     _prefsObserver(newPrefs, oldPrefs) {
       // Scan the preference objects one level deep to see if they differ.
       let differ = !oldPrefs;
@@ -618,7 +590,7 @@
 
       this.updateStyles(stylesToUpdate);
 
-      if (this.diff && this.comments && !this.noRenderOnPrefsChange) {
+      if (this.diff && !this.noRenderOnPrefsChange) {
         this._renderDiffTable();
       }
     },
@@ -645,15 +617,18 @@
       }
 
       this._showWarning = false;
-      const keyLocations = this._getKeyLocations(this.comments,
-          this.lineOfInterest);
-      this.$.diffBuilder.render(keyLocations, this._getBypassPrefs());
+
+      if (this.lineOfInterest) {
+        const side = this.lineOfInterest.leftSide ? 'left' : 'right';
+        this._keyLocations[side][this.lineOfInterest.number] = true;
+      }
+      this.$.diffBuilder.render(this._keyLocations, this._getBypassPrefs());
     },
 
     _handleRenderContent() {
       this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
         const addedThreadEls = info.addedNodes.filter(isThreadEl);
-        // In principal we should also handle removed nodes, but I have not
+        // In principle we should also handle removed nodes, but I have not
         // figured out how to do that yet without also catching all the removals
         // caused by further redistribution. Right now, comments are never
         // removed by no longer slotting them in, so I decided to not handle
@@ -665,8 +640,17 @@
               lineNumString, commentSide);
           const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
           const contentEl = contentText.parentElement;
-          const threadGroupEl = this._getOrCreateThreadGroup(contentEl);
-          Polymer.dom(threadGroupEl).appendChild(threadEl);
+          const threadGroupEl = this._getOrCreateThreadGroup(
+              contentEl, commentSide);
+          // Create a slot for the thread and attach it to the thread group.
+          // The Polyfill has some bugs and this only works if the slot is
+          // attached to the group after the group is attached to the DOM.
+          // The thread group may already have a slot with the right name, but
+          // that is okay because the first matching slot is used and the rest
+          // are ignored.
+          const slot = document.createElement('slot');
+          slot.name = threadEl.slot;
+          Polymer.dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
         }
       });
     },
@@ -684,39 +668,6 @@
     },
 
     /**
-     * Returns the key locations based on the comments and line of interests,
-     * where lines should not be collapsed.
-     *
-     * @param {!Object} comments
-     * @param {Defs.LineOfInterest|null} lineOfInterest
-     *
-     * @return {{left: Object<(string|number), boolean>,
-     *     right: Object<(string|number), boolean>}}
-     */
-    _getKeyLocations(comments, lineOfInterest) {
-      const result = {
-        left: {},
-        right: {},
-      };
-      for (const side in comments) {
-        if (side !== GrDiffBuilder.Side.LEFT &&
-            side !== GrDiffBuilder.Side.RIGHT) {
-          continue;
-        }
-        for (const c of comments[side]) {
-          result[side][c.line || GrDiffLine.FILE] = true;
-        }
-      }
-
-      if (lineOfInterest) {
-        const side = lineOfInterest.leftSide ? 'left' : 'right';
-        result[side][lineOfInterest.number] = true;
-      }
-
-      return result;
-    },
-
-    /**
      * Get the preferences object including the safety bypass context (if any).
      */
     _getBypassPrefs() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index c6ef806..99f2ded9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -192,107 +192,6 @@
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('remove comment', () => {
-        element.comments = {
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        };
-
-        element._removeComment({});
-        // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-        // to believe that one object deepEquals another even when they do :-/.
-        assert.equal(JSON.stringify(element.comments), JSON.stringify({
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        }));
-
-        element._removeComment({id: 'bc2', side: 'PARENT',
-          __commentSide: 'left'});
-        assert.deepEqual(element.comments, {
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-            {id: 'd2', __draft: true, __commentSide: 'right'},
-          ],
-        });
-
-        element._removeComment({id: 'd2', __commentSide: 'right'});
-        assert.deepEqual(element.comments, {
-          meta: {
-            changeNum: '42',
-            patchRange: {
-              basePatchNum: 'PARENT',
-              patchNum: 3,
-            },
-            path: '/path/to/foo',
-            projectConfig: {foo: 'bar'},
-          },
-          left: [
-            {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          ],
-          right: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-            {id: 'd1', __draft: true, __commentSide: 'right'},
-          ],
-        });
-      });
-
       test('thread groups', () => {
         const contentEl = document.createElement('div');
 
@@ -333,11 +232,6 @@
           };
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.comments = {
-            left: [],
-            right: [],
-            meta: {patchRange: undefined},
-          };
           element.isImageDiff = true;
           element.prefs = {
             auto_hide_diff_table_header: true,
@@ -663,11 +557,6 @@
         const setupDiff = function() {
           const mock = document.createElement('mock-diff-response');
           element.diff = mock.diffResponse;
-          element.comments = {
-            left: [],
-            right: [],
-            meta: {patchRange: undefined},
-          };
           element.prefs = {
             context: 10,
             tab_size: 8,
@@ -766,29 +655,6 @@
             change_type: 'MODIFIED',
             content: [{skip: 66}],
           };
-          element.comments = {
-            meta: {
-              changeNum: '42',
-              patchRange: {
-                basePatchNum: 'PARENT',
-                patchNum: 3,
-              },
-              path: '/path/to/foo',
-              projectConfig: {foo: 'bar'},
-            },
-            left: [
-              {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            ],
-            right: [
-              {id: 'c1', __commentSide: 'right'},
-              {id: 'c2', __commentSide: 'right'},
-              {id: 'd1', __draft: true, __commentSide: 'right'},
-              {id: 'd2', __draft: true, __commentSide: 'right'},
-            ],
-          };
         });
 
         test('change in preferences re-renders diff', () => {
@@ -807,86 +673,6 @@
           assert.isFalse(element._renderDiffTable.called);
         });
       });
-
-      suite('handle comment-update', () => {
-        setup(() => {
-          element.comments = {
-            meta: {
-              changeNum: '42',
-              patchRange: {
-                basePatchNum: 'PARENT',
-                patchNum: 3,
-              },
-              path: '/path/to/foo',
-              projectConfig: {foo: 'bar'},
-            },
-            left: [
-              {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-              {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-            ],
-            right: [
-              {id: 'c1', __commentSide: 'right'},
-              {id: 'c2', __commentSide: 'right'},
-              {id: 'd1', __draft: true, __commentSide: 'right'},
-              {id: 'd2', __draft: true, __commentSide: 'right'},
-            ],
-          };
-        });
-
-        test('creating a draft', () => {
-          const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-            __commentSide: 'left'};
-          element.fire('comment-update', {comment});
-          assert.include(element.comments.left, comment);
-        });
-
-        test('discarding a draft', () => {
-          const draftID = 'tempID';
-          const id = 'savedID';
-          const comment = {
-            __draft: true,
-            __draftID: draftID,
-            side: 'PARENT',
-            __commentSide: 'left',
-          };
-          const diffCommentsModifiedStub = sandbox.stub();
-          element.addEventListener('diff-comments-modified',
-              diffCommentsModifiedStub);
-          element.comments.left.push(comment);
-          comment.id = id;
-          element.fire('comment-discard', {comment});
-          const drafts = element.comments.left.filter(item => {
-            return item.__draftID === draftID;
-          });
-          assert.equal(drafts.length, 0);
-          assert.isTrue(diffCommentsModifiedStub.called);
-        });
-
-        test('saving a draft', () => {
-          const draftID = 'tempID';
-          const id = 'savedID';
-          const comment = {
-            __draft: true,
-            __draftID: draftID,
-            side: 'PARENT',
-            __commentSide: 'left',
-          };
-          const diffCommentsModifiedStub = sandbox.stub();
-          element.addEventListener('diff-comments-modified',
-              diffCommentsModifiedStub);
-          element.comments.left.push(comment);
-          comment.id = id;
-          element.fire('comment-save', {comment});
-          const drafts = element.comments.left.filter(item => {
-            return item.__draftID === draftID;
-          });
-          assert.equal(drafts.length, 1);
-          assert.equal(drafts[0].id, id);
-          assert.isTrue(diffCommentsModifiedStub.called);
-        });
-      });
     });
 
     suite('diff header', () => {
@@ -946,7 +732,6 @@
         const mock = document.createElement('mock-diff-response');
         sandbox.stub(element.$.diffBuilder, 'getDiffLength').returns(10000);
         element.diff = mock.diffResponse;
-        element.comments = {left: [], right: []};
         element.noRenderOnPrefsChange = true;
       });
 
@@ -1128,31 +913,55 @@
       });
     });
 
-    test('_getKeyLocations', () => {
-      assert.deepEqual(element._getKeyLocations({left: [], right: []}, null),
-          {left: {}, right: {}});
-      const comments = {
-        left: [{line: 123}, {}],
-        right: [{line: 456}],
-      };
-      assert.deepEqual(element._getKeyLocations(comments, null), {
-        left: {FILE: true, 123: true},
-        right: {456: true},
+    suite('key locations', () => {
+      let renderStub;
+
+      setup(() => {
+        element = fixture('basic');
+        element.prefs = {};
+        renderStub = sandbox.stub(element.$.diffBuilder, 'render');
       });
 
-      const lineOfInterest = {number: 789, leftSide: true};
-      assert.deepEqual(
-          element._getKeyLocations(comments, lineOfInterest), {
-            left: {FILE: true, 123: true, 789: true},
-            right: {456: true},
-          });
+      test('lineOfInterest is a key location', () => {
+        element.lineOfInterest = {number: 789, leftSide: true};
+        element._renderDiffTable();
+        assert.isTrue(renderStub.called);
+        assert.deepEqual(renderStub.lastCall.args[0], {
+          left: {789: true},
+          right: {},
+        });
+      });
 
-      delete lineOfInterest.leftSide;
-      assert.deepEqual(
-          element._getKeyLocations(comments, lineOfInterest), {
-            left: {FILE: true, 123: true},
-            right: {456: true, 789: true},
-          });
+      test('line comments are key locations', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'right');
+        threadEl.setAttribute('line-num', 3);
+        Polymer.dom(element).appendChild(threadEl);
+        Polymer.dom.flush();
+
+        element._renderDiffTable();
+        assert.isTrue(renderStub.called);
+        assert.deepEqual(renderStub.lastCall.args[0], {
+          left: {},
+          right: {3: true},
+        });
+      });
+
+      test('file comments are key locations', () => {
+        const threadEl = document.createElement('div');
+        threadEl.className = 'comment-thread';
+        threadEl.setAttribute('comment-side', 'left');
+        Polymer.dom(element).appendChild(threadEl);
+        Polymer.dom.flush();
+
+        element._renderDiffTable();
+        assert.isTrue(renderStub.called);
+        assert.deepEqual(renderStub.lastCall.args[0], {
+          left: {FILE: true},
+          right: {},
+        });
+      });
     });
   });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index fa488f0..8cee1f4 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -167,6 +167,9 @@
       const ranges = this.get(['_rangesMap', side, lineNum]) || [];
       return ranges
           .map(range => {
+            // Make a copy, so that the normalization below does not mess with
+            // our map.
+            range = Object.assign({}, range);
             range.end = range.end === -1 ? line.text.length : range.end;
 
             // Normalize invalid ranges where the start is after the end but the
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
similarity index 95%
rename from polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
rename to polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
index c3a1de4..8c80b37 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.html
@@ -22,9 +22,9 @@
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
-<link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
+<link rel="import" href="../gr-comment/gr-comment.html">
 
-<dom-module id="gr-diff-comment-thread">
+<dom-module id="gr-comment-thread">
   <template>
     <style include="shared-styles">
       gr-button {
@@ -72,7 +72,7 @@
     <div id="container" class$="[[_computeHostClass(unresolved)]]">
       <template id="commentList" is="dom-repeat" items="[[_orderedComments]]"
           as="comment">
-        <gr-diff-comment
+        <gr-comment
             comment="{{comment}}"
             robot-button-disabled="[[_hideActions(_showActions, _lastComment)]]"
             change-num="[[changeNum]]"
@@ -85,7 +85,7 @@
             project-config="[[_projectConfig]]"
             on-create-fix-comment="_handleCommentFix"
             on-comment-discard="_handleCommentDiscard"
-            on-comment-save="_handleCommentSavedOrDiscarded"></gr-diff-comment>
+            on-comment-save="_handleCommentSavedOrDiscarded"></gr-comment>
       </template>
       <div id="commentInfoContainer"
           hidden$="[[_hideActions(_showActions, _lastComment)]]">
@@ -122,5 +122,5 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
   </template>
-  <script src="gr-diff-comment-thread.js"></script>
+  <script src="gr-comment-thread.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
similarity index 96%
rename from polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
rename to polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index a2439d7..11fce6d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -21,7 +21,7 @@
   const NEWLINE_PATTERN = /\n/g;
 
   Polymer({
-    is: 'gr-diff-comment-thread',
+    is: 'gr-comment-thread',
 
     /**
      * Fired when the thread should be discarded.
@@ -36,7 +36,7 @@
      */
 
      /**
-      * gr-diff-comment-thread exposes the following attributes that allow a
+      * gr-comment-thread exposes the following attributes that allow a
       * diff widget like gr-diff to show the thread in the right location:
       *
       * line-num:
@@ -219,7 +219,7 @@
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       // Don’t preventDefault in this case because it will render the event
-      // useless for other handlers (other gr-diff-comment-thread elements).
+      // useless for other handlers (other gr-comment-thread elements).
       if (e.detail.keyboardEvent.shiftKey) {
         this._expandCollapseComments(true);
       } else {
@@ -230,7 +230,7 @@
 
     _expandCollapseComments(actionIsCollapse) {
       const comments =
-          Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+          Polymer.dom(this.root).querySelectorAll('gr-comment');
       for (const comment of comments) {
         comment.collapsed = actionIsCollapse;
       }
@@ -283,7 +283,7 @@
           parent.range);
 
       // If there is currently a comment in an editing state, add an attribute
-      // so that the gr-diff-comment knows not to populate the draft text.
+      // so that the gr-comment knows not to populate the draft text.
       for (let i = 0; i < this.comments.length; i++) {
         if (this.comments[i].__editing) {
           reply.__otherEditing = true;
@@ -350,7 +350,7 @@
     },
 
     _commentElWithDraftID(id) {
-      const els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      const els = Polymer.dom(this.root).querySelectorAll('gr-comment');
       for (const el of els) {
         if (el.comment.id === id || el.comment.__draftID === id) {
           return el;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
similarity index 95%
rename from polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
rename to polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
index 1881497..2e1b3bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
@@ -17,31 +17,31 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-diff-comment-thread</title>
+<title>gr-comment-thread</title>
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-diff-comment-thread.html">
+<link rel="import" href="gr-comment-thread.html">
 
 <script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
-    <gr-diff-comment-thread></gr-diff-comment-thread>
+    <gr-comment-thread></gr-comment-thread>
   </template>
 </test-fixture>
 
 <test-fixture id="withComment">
   <template>
-    <gr-diff-comment-thread></gr-diff-comment-thread>
+    <gr-comment-thread></gr-comment-thread>
   </template>
 </test-fixture>
 
 <script>
-  suite('gr-diff-comment-thread tests', () => {
+  suite('gr-comment-thread tests', () => {
     let element;
     let sandbox;
 
@@ -248,7 +248,7 @@
     });
 
     test('reply', () => {
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       const reportStub = sandbox.stub(element.$.reporting,
           'recordDraftInteraction');
       assert.ok(commentEl);
@@ -267,7 +267,7 @@
     });
 
     test('quote reply', () => {
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       const reportStub = sandbox.stub(element.$.reporting,
           'recordDraftInteraction');
       assert.ok(commentEl);
@@ -300,7 +300,7 @@
       }];
       flushAsynchronousOperations();
 
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       assert.ok(commentEl);
 
       const quoteBtn = element.$.quoteBtn;
@@ -323,7 +323,7 @@
       element.changeNum = '42';
       element.patchNum = '1';
 
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       assert.ok(commentEl);
 
       const ackBtn = element.$.ackBtn;
@@ -346,7 +346,7 @@
           'recordDraftInteraction');
       element.changeNum = '42';
       element.patchNum = '1';
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       assert.ok(commentEl);
 
       const doneBtn = element.$.doneBtn;
@@ -368,12 +368,12 @@
       element.changeNum = '42';
       element.patchNum = '1';
       element.path = '/path/to/file.txt';
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       assert.ok(commentEl);
 
       const saveOrDiscardStub = sandbox.stub();
       element.addEventListener('thread-changed', saveOrDiscardStub);
-      element.$$('gr-diff-comment')._fireSave();
+      element.$$('gr-comment')._fireSave();
 
       flush(() => {
         assert.isTrue(saveOrDiscardStub.called);
@@ -389,7 +389,7 @@
     test('please fix', done => {
       element.changeNum = '42';
       element.patchNum = '1';
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       assert.ok(commentEl);
       commentEl.addEventListener('create-fix-comment', () => {
         const drafts = element._orderedComments.filter(c => {
@@ -420,7 +420,7 @@
       const saveOrDiscardStub = sandbox.stub();
       element.addEventListener('thread-changed', saveOrDiscardStub);
       const draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+          Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
       assert.ok(draftEl);
       draftEl.addEventListener('comment-discard', () => {
         const drafts = element.comments.filter(c => {
@@ -452,7 +452,7 @@
           const saveOrDiscardStub = sandbox.stub();
           element.addEventListener('thread-changed', saveOrDiscardStub);
           const draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[0];
+          Polymer.dom(element.root).querySelectorAll('gr-comment')[0];
           assert.ok(draftEl);
           draftEl.addEventListener('comment-discard', () => {
             assert.equal(element.comments.length, 0);
@@ -534,7 +534,7 @@
       flushAsynchronousOperations();
 
       const draftEl =
-      Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      Polymer.dom(element.root).querySelectorAll('gr-comment')[1];
       assert.ok(draftEl);
       draftEl.addEventListener('comment-discard', () => {
         assert.isFalse(storageStub.called);
@@ -546,7 +546,7 @@
     });
 
     test('comment-update', () => {
-      const commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-comment');
       const updatedComment = {
         id: element.comments[0].id,
         foo: 'bar',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
similarity index 97%
rename from polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
rename to polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
index 72c7285..a470285 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.html
@@ -35,7 +35,7 @@
 <link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
 <script src="../../../scripts/rootElement.js"></script>
 
-<dom-module id="gr-diff-comment">
+<dom-module id="gr-comment">
   <template>
     <style include="shared-styles">
       :host {
@@ -232,10 +232,7 @@
         display: block;
       }
     </style>
-    <div id="container"
-        class="container"
-        on-mouseenter="_handleMouseEnter"
-        on-mouseleave="_handleMouseLeave">
+    <div id="container" class="container">
       <div class="header" id="header" on-tap="_handleToggleCollapsed">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
@@ -387,5 +384,5 @@
     <gr-storage id="storage"></gr-storage>
     <gr-reporting id="reporting"></gr-reporting>
   </template>
-  <script src="gr-diff-comment.js"></script>
+  <script src="gr-comment.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
similarity index 97%
rename from polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
rename to polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index 5165db0..a576583 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -32,7 +32,7 @@
   const FILE = 'FILE';
 
   Polymer({
-    is: 'gr-diff-comment',
+    is: 'gr-comment',
 
     /**
      * Fired when the create fix comment action is triggered.
@@ -59,14 +59,6 @@
      */
 
     /**
-     * @event comment-mouse-over
-     */
-
-    /**
-     * @event comment-mouse-out
-     */
-
-    /**
      * Fired when the comment's timestamp is tapped.
      *
      * @event comment-anchor-tap
@@ -610,14 +602,6 @@
       }
     },
 
-    _handleMouseEnter(e) {
-      this.fire('comment-mouse-over', this._getEventPayload());
-    },
-
-    _handleMouseLeave(e) {
-      this.fire('comment-mouse-out', this._getEventPayload());
-    },
-
     _handleToggleResolved() {
       this.$.reporting.recordDraftInteraction();
       this.resolved = !this.resolved;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
similarity index 98%
rename from polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
rename to polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
index 912e615..7ca5242 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
@@ -17,7 +17,7 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-diff-comment</title>
+<title>gr-comment</title>
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
@@ -25,19 +25,19 @@
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="gr-diff-comment.html">
+<link rel="import" href="gr-comment.html">
 
 <script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
-    <gr-diff-comment></gr-diff-comment>
+    <gr-comment></gr-comment>
   </template>
 </test-fixture>
 
 <test-fixture id="draft">
   <template>
-    <gr-diff-comment draft="true"></gr-diff-comment>
+    <gr-comment draft="true"></gr-comment>
   </template>
 </test-fixture>
 
@@ -48,7 +48,7 @@
     return getComputedStyle(el).getPropertyValue('display') !== 'none';
   }
 
-  suite('gr-diff-comment tests', () => {
+  suite('gr-comment tests', () => {
     let element;
     let sandbox;
     setup(() => {
@@ -343,7 +343,7 @@
     });
   });
 
-  suite('gr-diff-comment draft tests', () => {
+  suite('gr-comment draft tests', () => {
     let element;
     let sandbox;
 
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 20a4a1e..3b91650 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -103,8 +103,6 @@
     'core/gr-smart-search/gr-smart-search_test.html',
     'diff/gr-comment-api/gr-comment-api_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
-    'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
     'diff/gr-diff-highlight/gr-diff-highlight_test.html',
@@ -157,6 +155,8 @@
     'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
     'shared/gr-change-status/gr-change-status_test.html',
+    'shared/gr-comment-thread/gr-comment-thread_test.html',
+    'shared/gr-comment/gr-comment_test.html',
     'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
     'shared/gr-date-formatter/gr-date-formatter_test.html',
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index c5faa49..75f632d 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -56,6 +56,9 @@
       <name>Luca Milanesio</name>
     </developer>
     <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 52b11c1..cb8494b 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -56,6 +56,9 @@
       <name>Luca Milanesio</name>
     </developer>
     <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index d22c3ee..f58a6c7 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -56,6 +56,9 @@
       <name>Luca Milanesio</name>
     </developer>
     <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index e6c04e2..9849237 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -56,6 +56,9 @@
       <name>Luca Milanesio</name>
     </developer>
     <developer>
+      <name>Marco Miller</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>