Merge "Fix polygerrit_plugin rule to include deps"
diff --git a/.gitignore b/.gitignore
index 8cd43dd..c0e14ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,18 @@
 /local.properties
 /node_modules/
 /package-lock.json
-/plugins/cookbook-plugin/
+/plugins/*
+!/plugins/BUILD
+!/plugins/codemirror-editor
+!/plugins/commit-message-length-validator
+!/plugins/delete-project
+!/plugins/download-commands
+!/plugins/external_plugin_deps.bzl
+!/plugins/gitiles
+!/plugins/hooks
+!/plugins/replication
+!/plugins/reviewnotes
+!/plugins/singleusergroup
+!/plugins/webhooks
 /test_site
 /tools/format
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index f2bd273e..1d10025 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -860,55 +860,53 @@
 ----
 
 [[query_attributes]]
-=== Query Attributes ===
+=== Change Attributes ===
 
-Plugins can provide additional attributes to be returned in Gerrit queries by
-implementing the ChangeAttributeFactory interface and registering it to the
-ChangeQueryProcessor.ChangeAttributeFactory class in the plugin module's
-'configure()' method. The new attribute(s) will be output under a "plugin"
-attribute in the change query output. This can be further controlled with an
-option registered in the Http and Ssh modules' 'configure*()' methods.
+Plugins can provide additional attributes to be returned from the Get Change and
+Query Change APIs by implementing implementing the `ChangeAttributeFactory`
+interface and adding it to the `DynamicSet` in the plugin module's `configure()`
+method. The new attribute(s) will be output under a `plugin` attribute in the
+change output. This can be further controlled by registering a class containing
+@Option declarations as a `DynamicBean`, annotated with the with HTTP/SSH
+commands on which the options should be available.
 
-The example below shows a plugin that adds two attributes ('exampleName' and
-'changeValue'), to the change query output, when the query command is provided
-the --myplugin-name--all option.
+The example below shows a plugin that adds two attributes (`exampleName` and
+`changeValue`), to the change query output, when the query command is provided
+the `--myplugin-name--all` option.
 
 [source, java]
 ----
 public class Module extends AbstractModule {
   @Override
   protected void configure() {
-    bind(ChangeAttributeFactory.class)
-        .annotatedWith(Exports.named("example"))
+    // Register attribute factory.
+    DynamicSet.bind(binder(), ChangeAttributeFactory.class)
         .to(AttributeFactory.class);
+
+    // Register options for GET /changes/X/change and /changes/X/detail.
+    bind(DynamicBean.class)
+        .annotatedWith(Exports.named(GetChange.class))
+        .to(MyChangeOptions.class);
+
+    // Register options for GET /changes/?q=...
+    bind(DynamicBean.class)
+        .annotatedWith(Exports.named(QueryChanges.class))
+        .to(MyChangeOptions.class);
+
+    // Register options for ssh gerrit query.
+    bind(DynamicBean.class)
+        .annotatedWith(Exports.named(Query.class))
+        .to(MyChangeOptions.class);
   }
 }
 
-public class MyQueryOptions implements DynamicBean {
+public class MyChangeOptions implements DynamicBean {
   @Option(name = "--all", usage = "Include plugin output")
   public boolean all = false;
 }
 
-public static class HttpModule extends HttpPluginModule {
-  @Override
-  protected void configureServlets() {
-    bind(DynamicBean.class)
-        .annotatedWith(Exports.named(QueryChanges.class))
-        .to(MyQueryOptions.class);
-  }
-}
-
-public static class SshModule extends PluginCommandModule {
-  @Override
-  protected void configureCommands() {
-    bind(DynamicBean.class)
-        .annotatedWith(Exports.named(Query.class))
-        .to(MyQueryOptions.class);
-  }
-}
-
 public class AttributeFactory implements ChangeAttributeFactory {
-  protected MyQueryOptions options;
+  protected MyChangeOptions options;
 
   public class PluginAttribute extends PluginDefinedInfo {
     public String exampleName;
@@ -921,9 +919,9 @@
   }
 
   @Override
-  public PluginDefinedInfo create(ChangeData c, ChangeQueryProcessor qp, String plugin) {
+  public PluginDefinedInfo create(ChangeData c, BeanProvider bp, String plugin) {
     if (options == null) {
-      options = (MyQueryOptions) qp.getDynamicBean(plugin);
+      options = (MyChangeOptions) bp.getDynamicBean(plugin);
     }
     if (options.all) {
       return new PluginAttribute(c);
@@ -951,6 +949,23 @@
    ],
     ...
 }
+
+curl http://localhost:8080/changes/1?myplugin-name--all
+
+Output:
+
+{
+  "_number": 1,
+  ...
+  "plugins": [
+    {
+      "name": "myplugin-name",
+      "example_name": "Attribute Example",
+      "change_value": "1"
+    }
+  ],
+  ...
+}
 ----
 
 [[simple-configuration]]
@@ -1661,7 +1676,7 @@
 
 [source,java]
 ----
-public class HttpModule extends HttpPluginModule {
+public class HttpModule extends ServletModule {
   @Override
   protected void configureServlets() {
     DynamicSet.bind(binder(), WebUiPlugin.class)
@@ -2205,9 +2220,9 @@
 /** Register the LfsApiServlet to listen on the default LFS protocol endpoint */
 import static com.google.gerrit.httpd.plugins.LfsPluginServlet.URL_REGEX;
 
-import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.inject.servlet.ServletModule;
 
-public class HttpModule extends HttpPluginModule {
+public class HttpModule extends ServletModule {
 
   @Override
   protected void configureServlets() {
diff --git a/WORKSPACE b/WORKSPACE
index a984c41..b4b1a64 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -156,18 +156,18 @@
     sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
 )
 
-FLOGGER_VERS = "0.3.1"
+FLOGGER_VERS = "0.4"
 
 maven_jar(
     name = "flogger",
     artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-    sha1 = "585030fe1ec709760cbef997a459729fb965df0e",
+    sha1 = "9c8863dcc913b56291c0c88e6d4ca9715b43df98",
 )
 
 maven_jar(
     name = "flogger-log4j-backend",
     artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-    sha1 = "d5085e3996bddc4b105d53b886190cc9a8811a9e",
+    sha1 = "17aa5e31daa1354187e14b6978597d630391c028",
 )
 
 maven_jar(
@@ -1054,12 +1054,12 @@
     sha1 = "76716d529710fc03d1d429b43e3cedd4419f78d4",
 )
 
-# When upgrading elasticsearch-rest-client, also upgrade http-niocore
+# When upgrading elasticsearch-rest-client, also upgrade httpcore-nio
 # and httpasyncclient as necessary.
 maven_jar(
     name = "elasticsearch-rest-client",
-    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.6.1",
-    sha1 = "dc1c9284ffca28cd169fae2776c3956e90b76c00",
+    artifact = "org.elasticsearch.client:elasticsearch-rest-client:6.6.2",
+    sha1 = "2c429141e488091c358aa43b1e6873d457464c5d",
 )
 
 JACKSON_VERSION = "2.9.8"
@@ -1070,18 +1070,18 @@
     sha1 = "0f5a654e4675769c716e5b387830d19b501ca191",
 )
 
-TESTCONTAINERS_VERSION = "1.10.3"
+TESTCONTAINERS_VERSION = "1.10.7"
 
 maven_jar(
     name = "testcontainers",
     artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-    sha1 = "e561ce99fc616b383d85f35ce881e58e8de59ae7",
+    sha1 = "e7575fedfd010ca1ad80c8c9bf971a8057b1ff8a",
 )
 
 maven_jar(
     name = "testcontainers-elasticsearch",
     artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-    sha1 = "0cb114ecba0ed54a116e2be2f031bc45ca4cbfc8",
+    sha1 = "1ee43ebd81aea1f29bf60a56643bad80c134f998",
 )
 
 maven_jar(
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
new file mode 100644
index 0000000..ccd30ab
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -0,0 +1,201 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.restapi.change.GetChange;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.List;
+import java.util.Objects;
+import org.kohsuke.args4j.Option;
+
+public class AbstractPluginFieldsTest extends AbstractDaemonTest {
+  protected static class MyInfo extends PluginDefinedInfo {
+    @Nullable String theAttribute;
+
+    public MyInfo(@Nullable String theAttribute) {
+      this.theAttribute = theAttribute;
+    }
+
+    MyInfo(String name, @Nullable String theAttribute) {
+      this.name = requireNonNull(name);
+      this.theAttribute = theAttribute;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof MyInfo)) {
+        return false;
+      }
+      MyInfo i = (MyInfo) o;
+      return Objects.equals(name, i.name) && Objects.equals(theAttribute, i.theAttribute);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(name, theAttribute);
+    }
+
+    @Override
+    public String toString() {
+      return MoreObjects.toStringHelper(this)
+          .add("name", name)
+          .add("theAttribute", theAttribute)
+          .toString();
+    }
+  }
+
+  protected static class NullAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class).toInstance((cd, bp, p) -> null);
+    }
+  }
+
+  protected static class SimpleAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
+    }
+  }
+
+  private static class MyOptions implements DynamicBean {
+    @Option(name = "--opt")
+    private String opt;
+  }
+
+  protected static class OptionAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+          .toInstance(
+              (cd, bp, p) -> {
+                MyOptions opts = (MyOptions) bp.getDynamicBean(p);
+                return opts != null ? new MyInfo("opt " + opts.opt) : null;
+              });
+      bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+      bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
+    }
+  }
+
+  protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", NullAttributeModule.class)) {
+      assertThat(getter.call(id)).isNull();
+    }
+
+    assertThat(getter.call(id)).isNull();
+  }
+
+  protected void getChangeWithSimpleAttribute(PluginInfoGetter getter) throws Exception {
+    getChangeWithSimpleAttribute(getter, SimpleAttributeModule.class);
+  }
+
+  protected void getChangeWithSimpleAttribute(
+      PluginInfoGetter getter, Class<? extends Module> moduleClass) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", moduleClass)) {
+      assertThat(getter.call(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
+    }
+
+    assertThat(getter.call(id)).isNull();
+  }
+
+  protected void getChangeWithOption(
+      PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getterWithoutOptions.call(id)).isNull();
+
+    try (AutoCloseable ignored = installPlugin("my-plugin", OptionAttributeModule.class)) {
+      assertThat(getterWithoutOptions.call(id))
+          .containsExactly(new MyInfo("my-plugin", "opt null"));
+      assertThat(getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")))
+          .containsExactly(new MyInfo("my-plugin", "opt foo"));
+    }
+
+    assertThat(getterWithoutOptions.call(id)).isNull();
+  }
+
+  protected static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
+    assertThat(changeInfos).hasSize(1);
+    return pluginInfoFromChangeInfo(changeInfos.get(0));
+  }
+
+  protected static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+    List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
+    if (pluginInfo == null) {
+      return null;
+    }
+    return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
+  }
+
+  /**
+   * Decode {@code MyInfo}s from a raw list of maps returned from Gson.
+   *
+   * <p>This method is used instead of decoding {@code ChangeInfo} or {@code ChangAttribute}, since
+   * Gson would decode the {@code plugins} field as a {@code List<PluginDefinedInfo>}, which would
+   * return the base type and silently ignore any fields that are defined only in the subclass.
+   * Instead, decode the enclosing {@code ChangeInfo} or {@code ChangeAttribute} as a raw {@code
+   * Map<String, Object>}, and pass the {@code "plugins"} value to this method.
+   *
+   * @param gson Gson converter.
+   * @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
+   * @return decoded list of {@code MyInfo}s.
+   */
+  protected static List<MyInfo> decodeRawPluginsList(Gson gson, @Nullable Object plugins) {
+    if (plugins == null) {
+      return null;
+    }
+    checkArgument(plugins instanceof List, "not a list: %s", plugins);
+    return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
+  }
+
+  @FunctionalInterface
+  protected interface PluginInfoGetter {
+    List<MyInfo> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface PluginInfoGetterWithOptions {
+    List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
+        throws Exception;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 5e59007..d61cf7a 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -115,6 +115,8 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/sshd",
+        "//lib:args4j",
         "//lib:gson",
         "//lib:guava-retrying",
         "//lib:gwtorm",
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 2a3bf07..47ccb49 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -220,7 +221,18 @@
    */
   List<ReviewerInfo> reviewers() throws RestApiException;
 
-  ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException;
+  ChangeInfo get(
+      EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException;
+
+  default ChangeInfo get(ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException {
+    return get(EnumSet.noneOf(ListChangesOption.class), pluginOptions);
+  }
+
+  default ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException {
+    return get(options, ImmutableListMultimap.of());
+  }
 
   default ChangeInfo get(Iterable<ListChangesOption> options) throws RestApiException {
     return get(Sets.newEnumSet(options, ListChangesOption.class));
@@ -488,7 +500,9 @@
     }
 
     @Override
-    public ChangeInfo get(EnumSet<ListChangesOption> options) throws RestApiException {
+    public ChangeInfo get(
+        EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
+        throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index 1bafc90..bcb49de1 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -77,6 +79,7 @@
     private int start;
     private boolean isNoLimit;
     private EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+    private ListMultimap<String, String> pluginOptions = ArrayListMultimap.create();
 
     public abstract List<ChangeInfo> get() throws RestApiException;
 
@@ -118,6 +121,18 @@
       return this;
     }
 
+    /** Set a plugin option on the request, appending to existing options. */
+    public QueryRequest withPluginOption(String name, String value) {
+      this.pluginOptions.put(name, value);
+      return this;
+    }
+
+    /** Set a plugin option on the request, replacing existing options. */
+    public QueryRequest withPluginOptions(ListMultimap<String, String> options) {
+      this.pluginOptions = ArrayListMultimap.create(options);
+      return this;
+    }
+
     public String getQuery() {
       return query;
     }
@@ -138,6 +153,10 @@
       return options;
     }
 
+    public ListMultimap<String, String> getPluginOptions() {
+      return pluginOptions;
+    }
+
     @Override
     public String toString() {
       StringBuilder sb = new StringBuilder(getClass().getSimpleName()).append('{').append(query);
diff --git a/java/com/google/gerrit/extensions/registration/Extension.java b/java/com/google/gerrit/extensions/registration/Extension.java
index aaec201..1031bb0 100644
--- a/java/com/google/gerrit/extensions/registration/Extension.java
+++ b/java/com/google/gerrit/extensions/registration/Extension.java
@@ -32,7 +32,7 @@
   private final @Nullable String exportName;
   private final Provider<T> provider;
 
-  protected Extension(String pluginName, Provider<T> provider) {
+  public Extension(String pluginName, Provider<T> provider) {
     this(pluginName, null, provider);
   }
 
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
index 937b24a..279903c 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.httpd.plugins;
 
 import com.google.gerrit.extensions.api.lfs.LfsDefinitions;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.ResourceWeigher;
-import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -65,7 +63,5 @@
                 .weigher(ResourceWeigher.class);
           }
         });
-
-    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
   }
 }
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 2087104..b700835 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
@@ -72,7 +73,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
@@ -112,8 +112,8 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
-    bind(new TypeLiteral<DynamicMap<ChangeQueryProcessor.ChangeAttributeFactory>>() {})
-        .toInstance(DynamicMap.emptyMap());
+    bind(new TypeLiteral<DynamicSet<ChangeAttributeFactory>>() {})
+        .toInstance(DynamicSet.emptySet());
     bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
         .toInstance(DynamicMap.emptyMap());
     bind(String.class)
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 11d3336..44d3493 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -153,6 +153,21 @@
    */
   public interface BeanReceiver {
     void setDynamicBean(String plugin, DynamicBean dynamicBean);
+
+    /**
+     * Returns the class that should be used for looking up exported DynamicBean bindings from
+     * plugins. Override when a particular REST/SSH endpoint should respect DynamicBeans bound on a
+     * different endpoint. For example, {@code GetDetail} is just a synonym for a variant of {@code
+     * GetChange}, and it should respect any DynamicBeans on GetChange. GetChange}. So it should
+     * return {@code GetChange.class} from this method.
+     */
+    default Class<? extends BeanReceiver> getExportedBeanReceiver() {
+      return getClass();
+    }
+  }
+
+  public interface BeanProvider {
+    DynamicBean getDynamicBean(String plugin);
   }
 
   /**
@@ -195,9 +210,13 @@
     this.bean = bean;
     this.injector = injector;
     beansByPlugin = new HashMap<>();
+    Class<?> beanClass =
+        (bean instanceof BeanReceiver)
+            ? ((BeanReceiver) bean).getExportedBeanReceiver()
+            : getClass();
     for (String plugin : dynamicBeans.plugins()) {
       Provider<DynamicBean> provider =
-          dynamicBeans.byPlugin(plugin).get(bean.getClass().getCanonicalName());
+          dynamicBeans.byPlugin(plugin).get(beanClass.getCanonicalName());
       if (provider != null) {
         beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
       }
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
index 910ecd3..d4f11f0 100644
--- a/java/com/google/gerrit/server/api/BUILD
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -12,6 +12,8 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/util/cli",
+        "//lib:args4j",
         "//lib:guava",
         "//lib:gwtorm",
         "//lib:servlet-api-3_1",
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index ad39633..6763f51 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -49,12 +51,14 @@
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SetPrivateOp;
@@ -68,6 +72,7 @@
 import com.google.gerrit.server.restapi.change.DeleteChange;
 import com.google.gerrit.server.restapi.change.DeletePrivate;
 import com.google.gerrit.server.restapi.change.GetAssignee;
+import com.google.gerrit.server.restapi.change.GetChange;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
@@ -97,14 +102,18 @@
 import com.google.gerrit.server.restapi.change.SubmittedTogether;
 import com.google.gerrit.server.restapi.change.SuggestChangeReviewers;
 import com.google.gerrit.server.restapi.change.Unignore;
+import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.kohsuke.args4j.CmdLineException;
 
 class ChangeApiImpl implements ChangeApi {
   interface Factory {
@@ -132,7 +141,7 @@
   private final PutTopic putTopic;
   private final ChangeIncludedIn includedIn;
   private final PostReviewers postReviewers;
-  private final ChangeJson.Factory changeJson;
+  private final Provider<GetChange> getChangeProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
   private final PutAssignee putAssignee;
@@ -157,6 +166,7 @@
   private final PutMessage putMessage;
   private final Provider<GetPureRevert> getPureRevertProvider;
   private final StarredChangesUtil stars;
+  private final DynamicOptionParser dynamicOptionParser;
 
   @Inject
   ChangeApiImpl(
@@ -180,7 +190,7 @@
       PutTopic putTopic,
       ChangeIncludedIn includedIn,
       PostReviewers postReviewers,
-      ChangeJson.Factory changeJson,
+      Provider<GetChange> getChangeProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
       PutAssignee putAssignee,
@@ -205,6 +215,7 @@
       PutMessage putMessage,
       Provider<GetPureRevert> getPureRevertProvider,
       StarredChangesUtil stars,
+      DynamicOptionParser dynamicOptionParser,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -226,7 +237,7 @@
     this.putTopic = putTopic;
     this.includedIn = includedIn;
     this.postReviewers = postReviewers;
-    this.changeJson = changeJson;
+    this.getChangeProvider = getChangeProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
     this.putAssignee = putAssignee;
@@ -251,6 +262,7 @@
     this.putMessage = putMessage;
     this.getPureRevertProvider = getPureRevertProvider;
     this.stars = stars;
+    this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
   }
 
@@ -452,9 +464,14 @@
   }
 
   @Override
-  public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
+  public ChangeInfo get(
+      EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions)
+      throws RestApiException {
     try {
-      return changeJson.create(s).format(change);
+      GetChange getChange = getChangeProvider.get();
+      options.forEach(getChange::addOption);
+      dynamicOptionParser.parseDynamicOptions(getChange, pluginOptions);
+      return getChange.apply(change).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot retrieve change", e);
     }
@@ -660,4 +677,36 @@
       throw asRestApiException("Cannot parse change message " + id, e);
     }
   }
+
+  @Singleton
+  static class DynamicOptionParser {
+    private final CmdLineParser.Factory cmdLineParserFactory;
+    private final Injector injector;
+    private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
+
+    @Inject
+    DynamicOptionParser(
+        CmdLineParser.Factory cmdLineParserFactory,
+        Injector injector,
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+      this.cmdLineParserFactory = cmdLineParserFactory;
+      this.injector = injector;
+      this.dynamicBeans = dynamicBeans;
+    }
+
+    void parseDynamicOptions(Object bean, ListMultimap<String, String> pluginOptions)
+        throws BadRequestException {
+      CmdLineParser clp = cmdLineParserFactory.create(bean);
+      DynamicOptions dynamicOptions = new DynamicOptions(bean, injector, dynamicBeans);
+      dynamicOptions.parseDynamicBeans(clp);
+      dynamicOptions.setDynamicBeans();
+      dynamicOptions.onBeanParseStart();
+      try {
+        clp.parseOptionMap(pluginOptions);
+      } catch (CmdLineException | NumberFormatException e) {
+        throw new BadRequestException(e.getMessage(), e);
+      }
+      dynamicOptions.onBeanParseEnd();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index 0fdaeaa..acff137 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.api.changes.ChangeApiImpl.DynamicOptionParser;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.gerrit.server.restapi.change.CreateChange;
 import com.google.gerrit.server.restapi.change.QueryChanges;
@@ -43,6 +44,7 @@
   private final ChangesCollection changes;
   private final ChangeApiImpl.Factory api;
   private final CreateChange createChange;
+  private final DynamicOptionParser dynamicOptionParser;
   private final Provider<QueryChanges> queryProvider;
 
   @Inject
@@ -50,10 +52,12 @@
       ChangesCollection changes,
       ChangeApiImpl.Factory api,
       CreateChange createChange,
+      DynamicOptionParser dynamicOptionParser,
       Provider<QueryChanges> queryProvider) {
     this.changes = changes;
     this.api = api;
     this.createChange = createChange;
+    this.dynamicOptionParser = dynamicOptionParser;
     this.queryProvider = queryProvider;
   }
 
@@ -120,6 +124,7 @@
     for (ListChangesOption option : q.getOptions()) {
       qc.addOption(option);
     }
+    dynamicOptionParser.parseDynamicOptions(qc, q.getPluginOptions());
 
     try {
       List<?> result = qc.apply(TopLevelResource.INSTANCE);
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
new file mode 100644
index 0000000..95355cf
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+
+/**
+ * Interface for plugins to provide additional fields in {@link
+ * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
+ *
+ * <p>Register a {@code ChangeAttributeFactory} in a plugin {@code Module} like this:
+ *
+ * <pre>
+ * DynamicSet.bind(binder(), ChangeAttributeFactory.class).to(YourClass.class);
+ * </pre>
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
+ * developer documentation for more details and examples.
+ */
+public interface ChangeAttributeFactory {
+
+  /**
+   * Create a plugin-provided info field.
+   *
+   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
+   *
+   * @param cd change.
+   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
+   * @param plugin plugin name.
+   * @return the plugin's special change info.
+   */
+  PluginDefinedInfo create(ChangeData cd, BeanProvider beanProvider, String plugin);
+}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index e975c3d..fdb51c4 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -96,7 +96,6 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
-import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
new file mode 100644
index 0000000..9928125
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+/** Static helpers for use by {@link PluginDefinedAttributesFactory} implementations. */
+public class PluginDefinedAttributesFactories {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Nullable
+  public static ImmutableList<PluginDefinedInfo> createAll(
+      ChangeData cd,
+      BeanProvider beanProvider,
+      Stream<Extension<ChangeAttributeFactory>> attrFactories) {
+    ImmutableList<PluginDefinedInfo> result =
+        attrFactories
+            .map(e -> tryCreate(cd, beanProvider, e.getPluginName(), e.get()))
+            .filter(Objects::nonNull)
+            .collect(toImmutableList());
+    return !result.isEmpty() ? result : null;
+  }
+
+  @Nullable
+  private static PluginDefinedInfo tryCreate(
+      ChangeData cd, BeanProvider beanProvider, String plugin, ChangeAttributeFactory attrFactory) {
+    PluginDefinedInfo pdi = null;
+    try {
+      pdi = attrFactory.create(cd, beanProvider, plugin);
+    } catch (RuntimeException ex) {
+      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
+          "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
+    }
+    if (pdi != null) {
+      pdi.name = plugin;
+    }
+    return pdi;
+  }
+
+  private PluginDefinedAttributesFactories() {}
+}
diff --git a/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
similarity index 82%
rename from java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
rename to java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
index a795025..08d6ce7 100644
--- a/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactory.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2019 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.change;
 
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.query.change.ChangeData;
 import java.util.List;
 
 public interface PluginDefinedAttributesFactory {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 711efaf..e7baa55 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -76,6 +76,7 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
@@ -97,6 +98,7 @@
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
@@ -167,7 +169,6 @@
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -403,9 +404,10 @@
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
 
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
-    DynamicMap.mapOf(binder(), ChangeQueryProcessor.ChangeAttributeFactory.class);
+    DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index d15d0dd..f9263a9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
-import static java.util.concurrent.TimeUnit.MINUTES;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
-import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.Extension;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.IndexPredicate;
@@ -33,6 +33,9 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
@@ -42,9 +45,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -55,19 +56,7 @@
  * holding on to a single instance.
  */
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
-    implements DynamicOptions.BeanReceiver, PluginDefinedAttributesFactory {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  /**
-   * Register a ChangeAttributeFactory in a config Module like this:
-   *
-   * <p>bind(ChangeAttributeFactory.class) .annotatedWith(Exports.named("export-name"))
-   * .to(YourClass.class);
-   */
-  public interface ChangeAttributeFactory {
-    PluginDefinedInfo create(ChangeData a, ChangeQueryProcessor qp, String plugin);
-  }
-
+    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider {
   private final Provider<CurrentUser> userProvider;
   private final ChangeNotes.Factory notesFactory;
   private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
@@ -92,7 +81,7 @@
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
       ChangeNotes.Factory notesFactory,
-      DynamicMap<ChangeAttributeFactory> attributeFactories,
+      DynamicSet<ChangeAttributeFactory> attributeFactories,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       Provider<AnonymousUser> anonymousUserProvider) {
@@ -114,7 +103,7 @@
         ImmutableListMultimap.builder();
     // Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
     // Provider on every call, which could be expensive if we invoke it once for every change.
-    attributeFactories.forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
+    attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
     attributeFactoriesByPlugin = factoriesBuilder.build();
   }
 
@@ -135,32 +124,21 @@
     dynamicBeans.put(plugin, dynamicBean);
   }
 
+  @Override
   public DynamicBean getDynamicBean(String plugin) {
     return dynamicBeans.get(plugin);
   }
 
-  @Override
-  public List<PluginDefinedInfo> create(ChangeData cd) {
-    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactoriesByPlugin.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) {
-        // Log once a minute, to avoid spamming logs with one stack trace per change.
-        logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
-            "error populating attribute on change %s from plugin %s", cd.getId(), plugin);
-      }
-      if (pda != null) {
-        pda.name = plugin;
-        plugins.add(pda);
-      }
-    }
-    if (plugins.isEmpty()) {
-      plugins = null;
-    }
-    return plugins;
+  public PluginDefinedAttributesFactory getAttributesFactory() {
+    return this::buildPluginInfo;
+  }
+
+  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
+    return PluginDefinedAttributesFactories.createAll(
+        cd,
+        this,
+        attributeFactoriesByPlugin.entries().stream()
+            .map(e -> new Extension<>(e.getKey(), e::getValue)));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index ba11db9..09ff687 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -75,6 +75,8 @@
     JSON
   }
 
+  public static final Gson GSON = new Gson();
+
   private final GitRepositoryManager repoManager;
   private final ChangeQueryBuilder queryBuilder;
   private final ChangeQueryProcessor queryProcessor;
@@ -322,7 +324,7 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
-    c.plugins = queryProcessor.create(d);
+    c.plugins = queryProcessor.getAttributesFactory().create(d);
     return c;
   }
 
@@ -355,7 +357,7 @@
         break;
 
       case JSON:
-        out.print(new Gson().toJson(data));
+        out.print(GSON.toJson(data));
         out.print('\n');
         break;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 2decb4e..c8641d5 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -14,25 +14,41 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
 import org.kohsuke.args4j.Option;
 
-public class GetChange implements RestReadView<ChangeResource> {
+public class GetChange
+    implements RestReadView<ChangeResource>,
+        DynamicOptions.BeanReceiver,
+        DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
+  private final DynamicSet<ChangeAttributeFactory> attrFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+  private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
 
   @Option(name = "-o", usage = "Output options")
-  void addOption(ListChangesOption o) {
+  public void addOption(ListChangesOption o) {
     options.add(o);
   }
 
@@ -42,16 +58,36 @@
   }
 
   @Inject
-  GetChange(ChangeJson.Factory json) {
+  GetChange(ChangeJson.Factory json, DynamicSet<ChangeAttributeFactory> attrFactories) {
     this.json = json;
+    this.attrFactories = attrFactories;
+  }
+
+  @Override
+  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+    dynamicBeans.put(plugin, dynamicBean);
+  }
+
+  @Override
+  public DynamicBean getDynamicBean(String plugin) {
+    return dynamicBeans.get(plugin);
   }
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
+    return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
   Response<ChangeInfo> apply(RevisionResource rsrc) throws OrmException {
-    return Response.withMustRevalidate(json.create(options).format(rsrc));
+    return Response.withMustRevalidate(newChangeJson().format(rsrc));
+  }
+
+  private ChangeJson newChangeJson() {
+    return json.create(options, this::buildPluginInfo);
+  }
+
+  private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
+    return PluginDefinedAttributesFactories.createAll(
+        cd, this, Streams.stream(attrFactories.entries()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index ab75ab7..06fdd99 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -18,12 +18,14 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
-public class GetDetail implements RestReadView<ChangeResource> {
+public class GetDetail implements RestReadView<ChangeResource>, DynamicOptions.BeanReceiver {
   private final GetChange delegate;
 
   @Option(name = "-o", usage = "Output options")
@@ -47,6 +49,16 @@
   }
 
   @Override
+  public void setDynamicBean(String plugin, DynamicBean dynamicBean) {
+    delegate.setDynamicBean(plugin, dynamicBean);
+  }
+
+  @Override
+  public Class<? extends DynamicOptions.BeanReceiver> getExportedBeanReceiver() {
+    return delegate.getExportedBeanReceiver();
+  }
+
+  @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc) throws OrmException {
     return delegate.apply(rsrc);
   }
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 23b3011..6176ea0 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -143,7 +143,8 @@
 
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
-    List<List<ChangeInfo>> res = json.create(options, this.imp).format(results);
+    List<List<ChangeInfo>> res =
+        json.create(options, this.imp.getAttributesFactory()).format(results);
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index 1de14b6..c5f190e 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -20,10 +20,8 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Splitter;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.GerritConfigListener;
@@ -102,7 +100,6 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(SshPluginStarterCallback.class);
 
-    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicItem.itemOf(binder(), SshCreateCommandInterceptor.class);
 
     listener().toInstance(registerInParentInjectors());
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 555abc3..771451f 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -34,6 +34,7 @@
 
 package com.google.gerrit.util.cli;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
 import com.google.common.base.Strings;
@@ -426,6 +427,7 @@
 
     PrefixedOption(String prefix, Option o) {
       this.prefix = prefix;
+      checkArgument(o.name().startsWith("-"), "Option name must start with '-': %s", o);
       this.o = o;
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index d7083bc..9cc277f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -52,7 +52,6 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -74,10 +73,8 @@
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -117,7 +114,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
-import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
@@ -150,7 +146,6 @@
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryProcessor.ChangeAttributeFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -158,15 +153,12 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -2499,67 +2491,6 @@
     assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
   }
 
-  static class CustomAttributeModule extends AbstractModule {
-    static class MyInfo extends PluginDefinedInfo {
-      public String theAttribute;
-    }
-
-    @Override
-    public void configure() {
-      bind(ChangeAttributeFactory.class)
-          .annotatedWith(Exports.named("my-attr"))
-          .toInstance(
-              (cd, qp, plugin) -> {
-                try {
-                  String topic = cd.change().getTopic();
-                  if (Strings.isNullOrEmpty(topic)) {
-                    return null;
-                  }
-                  MyInfo myInfo = new MyInfo();
-                  myInfo.theAttribute = "topic: " + topic;
-                  return myInfo;
-                } catch (OrmException e) {
-                  throw new RuntimeException(e);
-                }
-              });
-    }
-  }
-
-  @Test
-  public void queryChangeWithCustomAttributes() throws Exception {
-    Change.Id id1 = createChange().getChange().getId();
-    Change.Id id2 = createChange().getChange().getId();
-    gApi.changes().id(id2.get()).topic("foo");
-
-    Function<Change.Id, QueryRequest> byQuery =
-        id ->
-            gApi.changes().query(id.toString()).withOptions(EnumSet.allOf(ListChangesOption.class));
-    assertThat(gApi.changes().id(id1.get()).get().plugins).isNull();
-    assertThat(gApi.changes().id(id2.get()).get().plugins).isNull();
-    assertThat(Iterables.getOnlyElement(byQuery.apply(id1).get()).plugins).isNull();
-    assertThat(Iterables.getOnlyElement(byQuery.apply(id2).get()).plugins).isNull();
-
-    try (AutoCloseable ignored = installPlugin("my-plugin", CustomAttributeModule.class)) {
-      // Plugin attributes only set in queries.
-      assertThat(gApi.changes().id(id1.get()).get().plugins).isNull();
-      assertThat(gApi.changes().id(id2.get()).get().plugins).isNull();
-
-      assertThat(Iterables.getOnlyElement(byQuery.apply(id1).get()).plugins).isNull();
-      List<PluginDefinedInfo> pluginInfos =
-          Iterables.getOnlyElement(byQuery.apply(id2).get()).plugins;
-      assertThat(pluginInfos).hasSize(1);
-      assertThat(pluginInfos.get(0)).isInstanceOf(CustomAttributeModule.MyInfo.class);
-      CustomAttributeModule.MyInfo myInfo = (CustomAttributeModule.MyInfo) pluginInfos.get(0);
-      assertThat(myInfo.name).isEqualTo("my-plugin");
-      assertThat(myInfo.theAttribute).isEqualTo("topic: foo");
-    }
-
-    assertThat(gApi.changes().id(id1.get()).get().plugins).isNull();
-    assertThat(gApi.changes().id(id2.get()).get().plugins).isNull();
-    assertThat(Iterables.getOnlyElement(byQuery.apply(id1).get()).plugins).isNull();
-    assertThat(Iterables.getOnlyElement(byQuery.apply(id2).get()).plugins).isNull();
-  }
-
   @Test
   public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
     PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
new file mode 100644
index 0000000..d5089ff
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.inject.AbstractModule;
+import org.junit.Test;
+
+@NoHttpd
+public class PluginFieldsIT extends AbstractPluginFieldsTest {
+  // No tests for /detail via the extension API, since the extension API doesn't have that method.
+
+  @Test
+  public void queryChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
+  }
+
+  @Test
+  public void getChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
+  }
+
+  @Test
+  public void queryChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()));
+  }
+
+  @Test
+  public void getChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()));
+  }
+
+  @Test
+  public void queryChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
+        (id, opts) ->
+            pluginInfoFromSingletonList(
+                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
+  }
+
+  @Test
+  public void getChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get()),
+        (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
+  }
+
+  static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(ChangeAttributeFactory.class)
+          .annotatedWith(Exports.named("simple"))
+          .toInstance((cd, bp, p) -> new MyInfo("change " + cd.getId()));
+    }
+  }
+
+  @Test
+  public void getChangeWithSimpleAttributeWithExplicitExport() throws Exception {
+    // For backwards compatibility with old plugins, allow modules to bind into the
+    // DynamicSet<ChangeAttributeFactory> as if it were a DynamicMap. We only need one variant of
+    // this test to prove that the mapping works.
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(gApi.changes().id(id.toString()).get()),
+        SimpleAttributeWithExplicitExportModule.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
new file mode 100644
index 0000000..649c7ae
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+public class PluginFieldsIT extends AbstractPluginFieldsTest {
+  private static final Gson GSON = OutputFormat.JSON.newGson();
+
+  @Test
+  public void queryChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void getChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
+  public void queryChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void getChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void getChangeDetailWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
+  public void queryChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
+        (id, opts) -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id, opts))));
+  }
+
+  @Test
+  public void getChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id))),
+        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
+  }
+
+  @Test
+  public void getChangeDetailWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
+        (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
+  }
+
+  private String changeQueryUrl(Change.Id id) {
+    return changeQueryUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeQueryUrl(Change.Id id, ImmutableListMultimap<String, String> opts) {
+    String url = "/changes/?q=" + id;
+    String queryString = buildQueryString(opts);
+    if (!queryString.isEmpty()) {
+      url += "&" + queryString;
+    }
+    return url;
+  }
+
+  private String changeUrl(Change.Id id) {
+    return changeUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeUrl(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "", pluginOptions);
+  }
+
+  private String changeDetailUrl(Change.Id id) {
+    return changeDetailUrl(id, ImmutableListMultimap.of());
+  }
+
+  private String changeDetailUrl(
+      Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return changeUrl(id, "/detail", pluginOptions);
+  }
+
+  private String changeUrl(
+      Change.Id id, String suffix, ImmutableListMultimap<String, String> pluginOptions) {
+    String url = "/changes/" + project + "~" + id + suffix;
+    String queryString = buildQueryString(pluginOptions);
+    if (!queryString.isEmpty()) {
+      url += "?" + queryString;
+    }
+    return url;
+  }
+
+  private static String buildQueryString(ImmutableListMultimap<String, String> opts) {
+    return Joiner.on('&').withKeyValueSeparator('=').join(opts.entries());
+  }
+
+  @Nullable
+  private static List<MyInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
+    res.assertOK();
+    List<Map<String, Object>> changeInfos =
+        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+    assertThat(changeInfos).hasSize(1);
+    return decodeRawPluginsList(GSON, changeInfos.get(0).get("plugins"));
+  }
+
+  @Nullable
+  private List<MyInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
+    res.assertOK();
+    Map<String, Object> changeInfo =
+        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+    return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
new file mode 100644
index 0000000..e61e2cc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.acceptance.AbstractPluginFieldsTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.query.change.OutputStreamQuery;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.Test;
+
+@UseSsh
+public class PluginChangeFieldsIT extends AbstractPluginFieldsTest {
+  // No tests for getting a single change over SSH, since the only API is the query API.
+
+  private static final Gson GSON = OutputStreamQuery.GSON;
+
+  @Test
+  public void queryChangeWithNullAttribute() throws Exception {
+    getChangeWithNullAttribute(
+        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
+  public void queryChangeWithSimpleAttribute() throws Exception {
+    getChangeWithSimpleAttribute(
+        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
+  @Test
+  public void queryChangeWithOption() throws Exception {
+    getChangeWithOption(
+        id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
+        (id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
+  }
+
+  private String changeQueryCmd(Change.Id id) {
+    return changeQueryCmd(id, ImmutableListMultimap.of());
+  }
+
+  private String changeQueryCmd(Change.Id id, ImmutableListMultimap<String, String> pluginOptions) {
+    return "gerrit query --format json "
+        + pluginOptions.entries().stream()
+            .flatMap(e -> Stream.of("--" + e.getKey(), e.getValue()))
+            .collect(joining(" "))
+        + " "
+        + id;
+  }
+
+  @Nullable
+  private static List<MyInfo> pluginInfoFromSingletonList(String sshOutput) throws Exception {
+    List<Map<String, Object>> changeAttrs = new ArrayList<>();
+    for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
+      Map<String, Object> changeAttr =
+          GSON.fromJson(line, new TypeToken<Map<String, Object>>() {}.getType());
+      if (!"stats".equals(changeAttr.get("type"))) {
+        changeAttrs.add(changeAttr);
+      }
+    }
+
+    assertThat(changeAttrs).hasSize(1);
+    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+  }
+}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index 79170c4..504ec46 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -47,7 +47,7 @@
       case V6_5:
         return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.5.4";
       case V6_6:
-        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.6.1";
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.6.2";
       case V7_0:
         return "docker.elastic.co/elasticsearch/elasticsearch-oss:7.0.0-beta1";
     }
diff --git a/plugins/replication b/plugins/replication
index a4bae3f..ff75ac8 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit a4bae3f491bb3f693e2407d353d3a81ce2a5c8be
+Subproject commit ff75ac8a4c806e5f627cb755f49e08962fa6e6b0