Merge "Escape marked-down angle brackets in release_noter" into stable-3.3
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 879ec99..5e318cf 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -3392,6 +3392,11 @@
 If `auth.type` is `LDAP` this setting should use `ldaps://` to
 ensure the end user's plaintext password is transmitted only over
 an encrypted connection.
++
+If you want to configure multiple ldap servers you can try to put
+multiple ldap urls separated by a space:
+`server = ldaps://ldap1 ldaps://ldap2`
+See https://bugs.chromium.org/p/gerrit/issues/detail?id=10841[issue 10841].
 
 [[ldap.startTls]]ldap.startTls::
 +
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index b4ae469..8f81775 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -864,13 +864,15 @@
 [[query_attributes]]
 === Change Attributes ===
 
+==== ChangePluginDefinedInfoFactory
+
 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.
+Query Change APIs by implementing the `ChangePluginDefinedInfoFactory` 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 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
@@ -882,7 +884,7 @@
   @Override
   protected void configure() {
     // Register attribute factory.
-    DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+    DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
         .to(AttributeFactory.class);
 
     // Register options for GET /changes/X/change and /changes/X/detail.
@@ -907,7 +909,7 @@
   public boolean all = false;
 }
 
-public class AttributeFactory implements ChangeAttributeFactory {
+public class AttributeFactory implements ChangePluginDefinedInfoFactory {
   protected MyChangeOptions options;
 
   public class PluginAttribute extends PluginDefinedInfo {
@@ -921,14 +923,17 @@
   }
 
   @Override
-  public PluginDefinedInfo create(ChangeData c, BeanProvider bp, String plugin) {
+  public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds, BeanProvider bp, String plugin) {
     if (options == null) {
       options = (MyChangeOptions) bp.getDynamicBean(plugin);
     }
+    Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
     if (options.all) {
-      return new PluginAttribute(c);
+      cds.forEach(cd -> out.put(cd.getId(), new PluginAttribute(cd)));
+      return out;
     }
-    return null;
+    return ImmutableMap.of();
   }
 }
 ----
@@ -970,10 +975,20 @@
 }
 ----
 
-Implementors of the `ChangeAttributeFactory` interface should check whether
-they need to contribute to the link:#change-etag-computation[change ETag
-computation] to prevent callers using ETags from potentially seeing outdated
-plugin attributes.
+Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
+are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
+
+==== ChangeAttributeFactory
+
+Alternatively, there is also `ChangeAttributeFactory` which takes in one single
+`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
+over this as it handles many changes at once which also decreases the round-trip
+time for queries resulting in performance increase for bulk queries.
+
+Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
+interfaces should check whether they need to contribute to the
+link:#change-etag-computation[change ETag computation] to prevent callers using
+ETags from potentially seeing outdated plugin attributes.
 
 [[simple-configuration]]
 == Simple Configuration in `gerrit.config`
diff --git a/Documentation/images/user-attention-set-dashboard.png b/Documentation/images/user-attention-set-dashboard.png
index 2bf7ccd..4533380 100644
--- a/Documentation/images/user-attention-set-dashboard.png
+++ b/Documentation/images/user-attention-set-dashboard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-icon.png b/Documentation/images/user-attention-set-icon.png
index a1d5ac5..a6789b9 100644
--- a/Documentation/images/user-attention-set-icon.png
+++ b/Documentation/images/user-attention-set-icon.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-modify.png b/Documentation/images/user-attention-set-reply-modify.png
index 7705d14..a8895f9 100644
--- a/Documentation/images/user-attention-set-reply-modify.png
+++ b/Documentation/images/user-attention-set-reply-modify.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-select.png b/Documentation/images/user-attention-set-reply-select.png
index 14fadfe3..e93ff58 100644
--- a/Documentation/images/user-attention-set-reply-select.png
+++ b/Documentation/images/user-attention-set-reply-select.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-user-prefs.png b/Documentation/images/user-attention-set-user-prefs.png
new file mode 100644
index 0000000..47cdbf5
--- /dev/null
+++ b/Documentation/images/user-attention-set-user-prefs.png
Binary files differ
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index f870405..7e219d4 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -1,10 +1,5 @@
 = Gerrit Code Review - Attention Set
 
-The Attention Set will be part of the upcoming 3.3 release (due late 2020).
-We are testing at on some hosts on `googlesource.com` right now. If you build
-your Gerrit from master, you can enable it using
-link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet].
-
 Report a bug or send feedback using
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
 You can also report a bug through the bug icon in the user hovercard and in the
@@ -16,7 +11,8 @@
 Code Review is a turn-based workflow going back and forth between the change
 owner and reviewers. For every change Gerrit maintains an "Attention Set" with
 users that are currently expected to act on the change. Both on the dashboard
-and on the change page, this is expressed by an arrow icon before the user name:
+and on the change page, this is expressed by an arrow icon before a (bolded)
+user name:
 
 image::images/user-attention-set-icon.png["account chip with attention icon", align="center"]
 
@@ -41,6 +37,7 @@
 changing the attention set:
 
 * If reviewers are added to a change, then they are added to the attention set.
+  * Exception: A reviewer adding themselves along with a comment or vote.
 * If an active change is submitted, abandoned or reset to "work in progress",
   then all users are removed from the attention set.
 * Replying (commenting, voting or just writing a change message) removes the
@@ -48,8 +45,8 @@
   conversations that the user is replying to.
 * If a *reviewer* replies, then the change owner (and uploader) are added to the
   attention set.
-* For merged and abandoned changes the owner is added when a new human comment
-  is created.
+* For merged and abandoned changes the owner is added only when a human creates
+  an unresolved comment.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 
 *!IMPORTANT!* These rules are not meant to be super smart and to always do the
@@ -99,18 +96,39 @@
 === Dashboard
 
 The default *dashboard* contains a new section at the top called "Your Turn". It
-lists all changes where the logged-in user is in the attention set.
+lists all changes where the logged-in user is in the attention set. When you are
+a reviewer, the change is highlighted and is shown at the top of the section.
+The "Waiting" column indicates how long the owner has already been waiting for
+you to act.
 
 image::images/user-attention-set-dashboard.png["dashboard with Your Turn section", align="center"]
 
-As an active developer one of your daily goals will be to iterate over this list
-and clear it.
+As an active developer, one of your daily goals will be to iterate over this
+list and clear it.
 
 image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your Turn section", align="center"]
 
 Note that you can also navigate to other users' dashboards to check their
 "Your Turn" section.
 
+=== Emails
+
+Every email begins with `Attention is currently required from: ...`, so you can
+identify at a glance whether you are expected to act.
+
+You can even change your email notification preferences in the user settings to
+only receive emails when you are in the attention set of a change:
+
+image::images/user-attention-set-user-prefs.png["user preference for email notifications", align="center"]
+
+If you prefer setting up customized filters in your mail client, then you can
+make use of the `Gerrit-Attention:` footer lines that are added for every user
+in the attention set, e.g.
+
+----
+Gerrit-Attention: Marian Harbach <mharbach@google.com>
+----
+
 === Assignee
 
 While the "Assignee" feature can still be used together with the attention set,
@@ -123,6 +141,9 @@
 Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
 recommend to only use one of them.
 
+If you don't expect action from reviewers, then consider adding them to CC
+instead.
+
 The "Assignee" feature can be turned on/off with the
 link:config-gerrit.html#change.enableAttentionSet[enableAssignee] config option.
 
@@ -133,6 +154,32 @@
 change page. This former way of keeping track of what you should look at has
 been replaced by the attention set.
 
+=== For Gerrit Admins
+
+The Attention Set will be part of the upcoming 3.3 release (due late 2020).
+We are testing it on `googlesource.com` right now. If you build your Gerrit from
+master, you can enable it using
+link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet].
+
+=== Important note for all host owners, project owners, and bot owners
+
+If you are a host/project owner, please make sure all bots that run against your
+host/project are part of the "Service Users" group.
+
+If you are a bot owner, please make sure your bot is part of the "Service Users"
+group on all hosts it runs on.
+
+To add users to the "Service Users" group, first ensure that the group exists on
+your host. If it doesn't, create it. The name must exactly be "Service Users".
+
+To create a group, use the Gerrit UI; BROWSE -> Groups -> CREATE NEW.
+
+Then, add the bots as members in this group. Alternatively, add an existing
+group that has multiple bots as a subgroup of "Service Users".
+
+To add members or subgroups, use the Gerrit UI; BROWSE -> Groups ->
+search for "Service Users" -> Members.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/WORKSPACE b/WORKSPACE
index 4c2fe35..3fd5dfe 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -875,48 +875,48 @@
     sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
 )
 
-JETTY_VERS = "9.4.30.v20200611"
+JETTY_VERS = "9.4.32.v20200930"
 
 maven_jar(
     name = "jetty-servlet",
     artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "ca3dea2cd34ee88cec017001603af0c9e74781d6",
+    sha1 = "4253dd46c099e0bca4dd763fc1e10774e10de00a",
 )
 
 maven_jar(
     name = "jetty-security",
     artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "1a5261f6ad4081ad9e9bb01416d639931d391273",
+    sha1 = "16a6110fa40e49050146de5f597ab3a3a3fa83b5",
 )
 
 maven_jar(
     name = "jetty-server",
     artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "e5ede3724d062717d0c04e4c77f74fe8115c2a6f",
+    sha1 = "d2d89099be5237cf68254bc943a7d800d3ee1945",
 )
 
 maven_jar(
     name = "jetty-jmx",
     artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "653559eaec0f9a335a0d12e90bc764b28f341241",
+    sha1 = "5e8e87a6f89b8eabf5b5b1765e3d758209001570",
 )
 
 maven_jar(
     name = "jetty-http",
     artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "cd6223382e4f82b9ea807d8cdb04a23e5d629f1c",
+    sha1 = "5fdcefd82178d11f895690f4fe6e843be69394b3",
 )
 
 maven_jar(
     name = "jetty-io",
     artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "9c360d08e903b2dbd5d1f8e889a32046948628ce",
+    sha1 = "0d0f32c3b511d6b3a542787f95ed229731588810",
 )
 
 maven_jar(
     name = "jetty-util",
     artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "39ec6aa4745952077f5407cb1394d8ba2db88b13",
+    sha1 = "efefd29006dcc9c9960a679263504287ce4e6896",
 )
 
 maven_jar(
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 020602b..a91bc49 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -21,26 +21,36 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 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.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.query.change.ChangeData;
 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.Inject;
 import com.google.inject.Module;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import org.kohsuke.args4j.Option;
 
 public class AbstractPluginFieldsTest extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+
   protected static class MyInfo extends PluginDefinedInfo {
     @Nullable String theAttribute;
 
@@ -91,6 +101,70 @@
     }
   }
 
+  protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+                return out;
+              });
+    }
+  }
+
+  protected static class PluginDefinedBulkExceptionModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                throw new RuntimeException("Sample Exception");
+              });
+    }
+  }
+
+  protected static class PluginDefinedChangesByCommitBulkAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .toInstance(
+              (cds, bp, p) -> {
+                Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+                cds.forEach(
+                    cd ->
+                        out.put(
+                            cd.getId(),
+                            !cd.commitMessage().contains("no-info")
+                                ? new MyInfo("change " + cd.getId())
+                                : null));
+                return out;
+              });
+    }
+  }
+
+  protected static class PluginDefinedSingleCallBulkAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .to(SingleCallBulkFactoryAttribute.class);
+    }
+  }
+
+  protected static class SingleCallBulkFactoryAttribute implements ChangePluginDefinedInfoFactory {
+    public static int timesCreateCalled = 0;
+
+    @Override
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+      timesCreateCalled++;
+      Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+      cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+      return out;
+    }
+  }
+
   private static class MyOptions implements DynamicBean {
     @Option(name = "--opt")
     private String opt;
@@ -111,6 +185,32 @@
     }
   }
 
+  public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
+    protected MyOptions opts;
+
+    @Override
+    public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+        Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+      if (opts == null) {
+        opts = (MyOptions) beanProvider.getDynamicBean(plugin);
+      }
+      Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+      cds.forEach(cd -> out.put(cd.getId(), new MyInfo("opt " + opts.opt)));
+      return out;
+    }
+  }
+
+  protected static class PluginDefinedOptionAttributeModule extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+          .to(BulkAttributeFactoryWithOption.class);
+      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();
@@ -138,6 +238,113 @@
     assertThat(getter.call(id)).isNull();
   }
 
+  protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call(id);
+    assertThat(pluginInfos.get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      pluginInfos = getter.call(id);
+      assertThat(pluginInfos.get(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
+    }
+
+    pluginInfos = getter.call(id);
+    assertThat(pluginInfos.get(id)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAttribute(BulkPluginInfoGetter getter)
+      throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+      assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
+  protected void getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id changeWithNoInfo = changeOperations.newChange().commitMessage("no-info").create();
+    Change.Id changeWithInfo = changeOperations.newChange().commitMessage("info").create();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+    assertThat(pluginInfos.get(changeWithInfo)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedChangesByCommitBulkAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+      assertThat(pluginInfos.get(changeWithInfo))
+          .containsExactly(new MyInfo("my-plugin", "change " + changeWithInfo));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+    assertThat(pluginInfos.get(changeWithInfo)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+            installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
+        AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
+      assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
+      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
+      assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
+  protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+      BulkPluginInfoGetter getter) throws Exception {
+    Change.Id id1 = createChange().getChange().getId();
+    Change.Id id2 = createChange().getChange().getId();
+    int timesCalled = SingleCallBulkFactoryAttribute.timesCreateCalled;
+
+    Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedSingleCallBulkAttributeModule.class)) {
+      pluginInfos = getter.call();
+      assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+      assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+      assertThat(SingleCallBulkFactoryAttribute.timesCreateCalled).isEqualTo(timesCalled + 1);
+    }
+
+    pluginInfos = getter.call();
+    assertThat(pluginInfos.get(id1)).isNull();
+    assertThat(pluginInfos.get(id2)).isNull();
+  }
+
   protected void getChangeWithOption(
       PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
       throws Exception {
@@ -154,17 +361,61 @@
     assertThat(getterWithoutOptions.call(id)).isNull();
   }
 
-  protected static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
+  protected void getChangeWithPluginDefinedBulkAttributeOption(
+      BulkPluginInfoGetterWithId getterWithoutOptions,
+      BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
+      throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedOptionAttributeModule.class)) {
+      assertThat(getterWithoutOptions.call(id).get(id))
+          .containsExactly(new MyInfo("my-plugin", "opt null"));
+      assertThat(
+              getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")).get(id))
+          .containsExactly(new MyInfo("my-plugin", "opt foo"));
+    }
+
+    assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+  }
+
+  protected void getChangeWithPluginDefinedBulkAttributeWithException(
+      BulkPluginInfoGetterWithId getter) throws Exception {
+    Change.Id id = createChange().getChange().getId();
+    assertThat(getter.call(id).get(id)).isNull();
+
+    try (AutoCloseable ignored =
+        installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
+      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+      List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
+      assertThat(outputInfos).hasSize(1);
+      assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
+      assertThat(outputInfos.get(0).message).isEqualTo("Something went wrong in plugin: my-plugin");
+    }
+
+    assertThat(getter.call(id).get(id)).isNull();
+  }
+
+  protected static List<PluginDefinedInfo> pluginInfoFromSingletonList(
+      List<ChangeInfo> changeInfos) {
     assertThat(changeInfos).hasSize(1);
     return pluginInfoFromChangeInfo(changeInfos.get(0));
   }
 
-  protected static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+  protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
     List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
       return null;
     }
-    return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
+    return pluginInfo.stream().map(PluginDefinedInfo.class::cast).collect(toImmutableList());
+  }
+
+  protected static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(
+      List<ChangeInfo> changeInfos) {
+    Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+    changeInfos.forEach(ci -> out.put(Change.id(ci._number), pluginInfoFromChangeInfo(ci)));
+    return out;
   }
 
   /**
@@ -180,7 +431,8 @@
    * @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) {
+  protected static List<PluginDefinedInfo> decodeRawPluginsList(
+      Gson gson, @Nullable Object plugins) {
     if (plugins == null) {
       return null;
     }
@@ -188,14 +440,44 @@
     return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
   }
 
+  protected static Map<Change.Id, List<PluginDefinedInfo>> getPluginInfosFromChangeInfos(
+      Gson gson, List<Map<String, Object>> changeInfos) {
+    Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+    changeInfos.forEach(
+        change -> {
+          Double changeId =
+              (Double)
+                  (change.get("number") != null ? change.get("number") : change.get("_number"));
+          out.put(
+              Change.id(changeId.intValue()), decodeRawPluginsList(gson, change.get("plugins")));
+        });
+    return out;
+  }
+
   @FunctionalInterface
   protected interface PluginInfoGetter {
-    List<MyInfo> call(Change.Id id) throws Exception;
+    List<PluginDefinedInfo> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetter {
+    Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetterWithId {
+    Map<Change.Id, List<PluginDefinedInfo>> call(Change.Id id) throws Exception;
+  }
+
+  @FunctionalInterface
+  protected interface BulkPluginInfoGetterWithIdAndOptions {
+    Map<Change.Id, List<PluginDefinedInfo>> call(
+        Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
   }
 
   @FunctionalInterface
   protected interface PluginInfoGetterWithOptions {
-    List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
+    List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
         throws Exception;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 0138290..f6e5de3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -96,7 +96,7 @@
     args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
     projectCreation.parent().ifPresent(p -> args.newParent = p);
     // ProjectCreator wants non-null owner IDs.
-    args.ownerIds = new ArrayList<>();
+    args.ownerIds = new ArrayList<>(projectCreation.owners());
     projectCreation.submitType().ifPresent(st -> args.submitType = st);
     projectCreator.createProject(args);
     return Project.nameKey(name);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 00759a0..3337fc3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.util.Optional;
@@ -39,6 +42,8 @@
 
   public abstract Optional<SubmitType> submitType();
 
+  public abstract ImmutableSet<AccountGroup.UUID> owners();
+
   abstract ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator();
 
   public static Builder builder(
@@ -76,6 +81,13 @@
       return createEmptyCommit(false);
     }
 
+    public TestProjectCreation.Builder addOwner(AccountGroup.UUID owner) {
+      ownersBuilder().add(requireNonNull(owner, "owner"));
+      return this;
+    }
+
+    abstract ImmutableSet.Builder<AccountGroup.UUID> ownersBuilder();
+
     abstract TestProjectCreation.Builder projectCreator(
         ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator);
 
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index e6fef0f..69bfa2c 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -16,4 +16,5 @@
 
 public class PluginDefinedInfo {
   public String name;
+  public String message;
 }
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 966801f..c8d69f1c 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -29,6 +29,10 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.LibModuleType;
+import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexModule;
@@ -159,6 +163,7 @@
     }
     modules.add(indexModule);
     modules.add(new BatchProgramModule());
+    modules.add(new H2CacheModule());
     modules.add(
         new FactoryModule() {
           @Override
@@ -167,7 +172,9 @@
           }
         });
 
-    return dbInjector.createChildInjector(modules);
+    return dbInjector.createChildInjector(
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadModules(dbInjector, LibModuleType.SYS_MODULE)));
   }
 
   private void overrideConfig() {
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 35ba3d0..e9c0136 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 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;
@@ -153,7 +152,7 @@
     install(new BatchGitModule());
     install(new DefaultPermissionBackendModule());
     install(new DefaultMemoryCacheModule());
-    install(new H2CacheModule());
+
     install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule());
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
index 95355cf..663d7aa 100644
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
@@ -32,6 +32,7 @@
  * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
  * developer documentation for more details and examples.
  */
+@Deprecated
 public interface ChangeAttributeFactory {
 
   /**
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 014955c9..8323cfd 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -39,6 +39,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
@@ -67,6 +68,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
@@ -156,13 +158,17 @@
     }
 
     public ChangeJson create(Iterable<ListChangesOption> options) {
-      return factory.create(options, Optional.empty());
+      return factory.create(options, Optional.empty(), Optional.empty());
     }
 
     public ChangeJson create(
         Iterable<ListChangesOption> options,
-        PluginDefinedAttributesFactory pluginDefinedAttributesFactory) {
-      return factory.create(options, Optional.of(pluginDefinedAttributesFactory));
+        PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
+        PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+      return factory.create(
+          options,
+          Optional.of(pluginDefinedAttributesFactory),
+          Optional.of(pluginDefinedInfosFactory));
     }
 
     public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -173,7 +179,8 @@
   public interface AssistedFactory {
     ChangeJson create(
         Iterable<ListChangesOption> options,
-        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory);
+        Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+        Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
   }
 
   @Singleton
@@ -220,6 +227,7 @@
   private final Metrics metrics;
   private final RevisionJson revisionJson;
   private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
+  private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
 
@@ -243,7 +251,8 @@
       RevisionJson.Factory revisionJsonFactory,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
-      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory) {
+      @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+      @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
     this.userProvider = user;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
@@ -261,6 +270,7 @@
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
     this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
+    this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
   }
@@ -279,12 +289,12 @@
   }
 
   public ChangeInfo format(ChangeData cd) {
-    return format(cd, Optional.empty(), true);
+    return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
   public ChangeInfo format(RevisionResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return format(cd, Optional.of(rsrc.getPatchSet().id()), true);
+    return format(cd, Optional.of(rsrc.getPatchSet().id()), true, getPluginInfos(cd));
   }
 
   public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
@@ -293,8 +303,10 @@
       accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
       List<List<ChangeInfo>> res = new ArrayList<>(in.size());
       Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
+      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+          getPluginInfos(in.stream().flatMap(e -> e.entities().stream()).collect(toList()));
       for (QueryResult<ChangeData> r : in) {
-        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
+        List<ChangeInfo> infos = toChangeInfos(r.entities(), cache, pluginInfosByChange);
         if (!infos.isEmpty() && r.more()) {
           infos.get(infos.size() - 1)._moreChanges = true;
         }
@@ -309,8 +321,9 @@
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
     ensureLoaded(in);
     List<ChangeInfo> out = new ArrayList<>(in.size());
+    ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange = getPluginInfos(in);
     for (ChangeData cd : in) {
-      out.add(format(cd, Optional.empty(), false));
+      out.add(format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId())));
     }
     accountLoader.fill();
     return out;
@@ -326,7 +339,8 @@
       }
       return checkOnly(changeDataFactory.create(project, id));
     }
-    return format(changeDataFactory.create(notes), Optional.empty(), true);
+    ChangeData cd = changeDataFactory.create(notes);
+    return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
   private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
@@ -358,15 +372,18 @@
   }
 
   private ChangeInfo format(
-      ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader) {
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      boolean fillAccountLoader,
+      List<PluginDefinedInfo> pluginInfosForChange) {
     try {
       if (fillAccountLoader) {
         accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-        ChangeInfo res = toChangeInfo(cd, limitToPsId);
+        ChangeInfo res = toChangeInfo(cd, limitToPsId, pluginInfosForChange);
         accountLoader.fill();
         return res;
       }
-      return toChangeInfo(cd, limitToPsId);
+      return toChangeInfo(cd, limitToPsId, pluginInfosForChange);
     } catch (PatchListNotAvailableException
         | GpgException
         | IOException
@@ -404,7 +421,9 @@
   }
 
   private List<ChangeInfo> toChangeInfos(
-      List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
+      List<ChangeData> changes,
+      Map<Change.Id, ChangeInfo> cache,
+      ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange) {
     try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
       List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
       for (int i = 0; i < changes.size(); i++) {
@@ -425,7 +444,7 @@
         // Compute and cache if possible
         try {
           ensureLoaded(Collections.singleton(cd));
-          info = format(cd, Optional.empty(), false);
+          info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
           changeInfos.add(info);
           if (isCacheable) {
             cache.put(Change.id(info._number), info);
@@ -480,14 +499,18 @@
     return info;
   }
 
-  private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private ChangeInfo toChangeInfo(
+      ChangeData cd,
+      Optional<PatchSet.Id> limitToPsId,
+      List<PluginDefinedInfo> pluginInfosForChange)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
-      return toChangeInfoImpl(cd, limitToPsId);
+      return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange);
     }
   }
 
-  private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+  private ChangeInfo toChangeInfoImpl(
+      ChangeData cd, Optional<PatchSet.Id> limitToPsId, List<PluginDefinedInfo> pluginInfos)
       throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
@@ -589,6 +612,15 @@
     if (pluginDefinedAttributesFactory.isPresent()) {
       out.plugins = pluginDefinedAttributesFactory.get().create(cd);
     }
+
+    if (!pluginInfos.isEmpty()) {
+      if (out.plugins == null) {
+        out.plugins = pluginInfos;
+      } else {
+        out.plugins = new ArrayList<>(out.plugins);
+        out.plugins.addAll(pluginInfos);
+      }
+    }
     out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
     out.submissionId = cd.change().getSubmissionId();
     out.cherryPickOfChange =
@@ -819,4 +851,16 @@
     }
     return map;
   }
+
+  private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
+    return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
+  }
+
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
+      Collection<ChangeData> cds) {
+    if (pluginDefinedInfosFactory.isPresent()) {
+      return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+    }
+    return ImmutableListMultimap.of();
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
new file mode 100644
index 0000000..c6ceb61
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Interface for plugins to provide additional fields in {@link
+ * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
+ *
+ * <p>Register a {@code ChangePluginDefinedInfoFactory} in a plugin {@code Module} like this:
+ *
+ * <pre>
+ * DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.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 ChangePluginDefinedInfoFactory {
+
+  /**
+   * Create a plugin-provided info field for each of the provided {@link ChangeData}s.
+   *
+   * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
+   *
+   * @param cds changes.
+   * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
+   * @param plugin plugin name.
+   * @return map of the plugin's special info for each change
+   */
+  Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds, BeanProvider beanProvider, String plugin);
+}
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index 9928125..b474dab 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -18,12 +18,15 @@
 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.common.Nullable;
+import com.google.gerrit.entities.Change;
 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.Collection;
 import java.util.Objects;
 import java.util.stream.Stream;
 
@@ -60,5 +63,44 @@
     return pdi;
   }
 
+  public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
+      Collection<ChangeData> cds,
+      BeanProvider beanProvider,
+      Stream<Extension<ChangePluginDefinedInfoFactory>> infoFactories) {
+    ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder =
+        ImmutableListMultimap.builder();
+    infoFactories.forEach(
+        e -> tryCreate(cds, beanProvider, e.getPluginName(), e.get(), pluginInfosByChangeBuilder));
+    ImmutableListMultimap<Change.Id, PluginDefinedInfo> result = pluginInfosByChangeBuilder.build();
+    return result;
+  }
+
+  private static void tryCreate(
+      Collection<ChangeData> cds,
+      BeanProvider beanProvider,
+      String plugin,
+      ChangePluginDefinedInfoFactory infoFactory,
+      ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder) {
+    try {
+      infoFactory
+          .createPluginDefinedInfos(cds, beanProvider, plugin)
+          .forEach(
+              (id, pdi) -> {
+                if (pdi != null) {
+                  pdi.name = plugin;
+                  pluginInfosByChangeBuilder.put(id, pdi);
+                }
+              });
+    } catch (RuntimeException ex) {
+      /* Propagate runtime exceptions as structured API data types so that queries don't fail. */
+      logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
+          "error populating attribute on changes from plugin %s", plugin);
+      PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+      errorInfo.name = plugin;
+      errorInfo.message = "Something went wrong in plugin: " + plugin;
+      cds.forEach(cd -> pluginInfosByChangeBuilder.put(cd.getId(), errorInfo));
+    }
+  }
+
   private PluginDefinedAttributesFactories() {}
 }
diff --git a/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
new file mode 100644
index 0000000..db57e29
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+
+/**
+ * Interface to generate {@code PluginDefinedInfo}s from registered {@code
+ * ChangePluginDefinedInfoFactory}s.
+ *
+ * <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 PluginDefinedInfosFactory {
+
+  /**
+   * Create a plugin-provided info field from all the plugins for each of the provided {@link
+   * ChangeData}s.
+   *
+   * @param cds changes.
+   * @return map of the all plugin's special infos for each change.
+   */
+  ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds);
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 22d02d2..78dd38c 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -110,6 +110,7 @@
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.LabelsJson;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -437,6 +438,7 @@
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
     DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
+    DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
 
     install(new GitwebConfig.LegacyModule(cfg));
 
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index 9c860c4..2816429 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -15,14 +15,40 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.common.UsedAt;
+import java.io.File;
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.NoWorkTreeException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.events.ListenerList;
+import org.eclipse.jgit.events.RepositoryEvent;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.BaseRepositoryBuilder;
 import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.RebaseTodoLine;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.ReflogReader;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryState;
 import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.FS;
 
 /** Wrapper around {@link Repository} that delegates all calls to the wrapped {@link Repository}. */
 @UsedAt(UsedAt.Project.PLUGIN_HIGH_AVAILABILITY)
@@ -90,4 +116,279 @@
 
     return new BaseRepositoryBuilder<>().setFS(repo.getFS()).setGitDir(repo.getDirectory());
   }
+
+  @Override
+  public ListenerList getListenerList() {
+    return delegate.getListenerList();
+  }
+
+  @Override
+  public void fireEvent(RepositoryEvent<?> event) {
+    delegate.fireEvent(event);
+  }
+
+  @Override
+  public void create() throws IOException {
+    delegate.create();
+  }
+
+  @Override
+  public File getDirectory() {
+    return delegate.getDirectory();
+  }
+
+  @Override
+  public ObjectInserter newObjectInserter() {
+    return delegate.newObjectInserter();
+  }
+
+  @Override
+  public ObjectReader newObjectReader() {
+    return delegate.newObjectReader();
+  }
+
+  @Override
+  public FS getFS() {
+    return delegate.getFS();
+  }
+
+  @Override
+  @Deprecated
+  public boolean hasObject(AnyObjectId objectId) {
+    return delegate.hasObject(objectId);
+  }
+
+  @Override
+  public ObjectLoader open(AnyObjectId objectId, int typeHint)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+    return delegate.open(objectId, typeHint);
+  }
+
+  @Override
+  public void incrementOpen() {
+    delegate.incrementOpen();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @Override
+  public String getFullBranch() throws IOException {
+    return delegate.getFullBranch();
+  }
+
+  @Override
+  public String getBranch() throws IOException {
+    return delegate.getBranch();
+  }
+
+  @Override
+  @Deprecated
+  public Map<String, Ref> getAllRefs() {
+    return delegate.getAllRefs();
+  }
+
+  @Override
+  @Deprecated
+  public Map<String, Ref> getTags() {
+    return delegate.getTags();
+  }
+
+  @Override
+  public DirCache lockDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+    return delegate.lockDirCache();
+  }
+
+  @Override
+  public void autoGC(ProgressMonitor monitor) {
+    delegate.autoGC(monitor);
+  }
+
+  @Override
+  public Set<ObjectId> getAdditionalHaves() {
+    return delegate.getAdditionalHaves();
+  }
+
+  @Override
+  public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() {
+    return delegate.getAllRefsByPeeledObjectId();
+  }
+
+  @Override
+  public File getIndexFile() throws NoWorkTreeException {
+    return delegate.getIndexFile();
+  }
+
+  @Override
+  public RepositoryState getRepositoryState() {
+    return delegate.getRepositoryState();
+  }
+
+  @Override
+  public boolean isBare() {
+    return delegate.isBare();
+  }
+
+  @Override
+  public File getWorkTree() throws NoWorkTreeException {
+    return delegate.getWorkTree();
+  }
+
+  @Override
+  public String getRemoteName(String refName) {
+    return delegate.getRemoteName(refName);
+  }
+
+  @Override
+  public String getGitwebDescription() throws IOException {
+    return delegate.getGitwebDescription();
+  }
+
+  @Override
+  public Set<String> getRemoteNames() {
+    return delegate.getRemoteNames();
+  }
+
+  @Override
+  public ObjectLoader open(AnyObjectId objectId) throws MissingObjectException, IOException {
+    return delegate.open(objectId);
+  }
+
+  @Override
+  public RefUpdate updateRef(String ref) throws IOException {
+    return delegate.updateRef(ref);
+  }
+
+  @Override
+  public RefUpdate updateRef(String ref, boolean detach) throws IOException {
+    return delegate.updateRef(ref, detach);
+  }
+
+  @Override
+  public RefRename renameRef(String fromRef, String toRef) throws IOException {
+    return delegate.renameRef(fromRef, toRef);
+  }
+
+  @Override
+  public ObjectId resolve(String revstr)
+      throws AmbiguousObjectException, IncorrectObjectTypeException, RevisionSyntaxException,
+          IOException {
+    return delegate.resolve(revstr);
+  }
+
+  @Override
+  public String simplify(String revstr) throws AmbiguousObjectException, IOException {
+    return delegate.simplify(revstr);
+  }
+
+  @Override
+  @Deprecated
+  public Ref peel(Ref ref) {
+    return delegate.peel(ref);
+  }
+
+  @Override
+  public RevCommit parseCommit(AnyObjectId id)
+      throws IncorrectObjectTypeException, IOException, MissingObjectException {
+    return delegate.parseCommit(id);
+  }
+
+  @Override
+  public DirCache readDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+    return delegate.readDirCache();
+  }
+
+  @Override
+  public String shortenRemoteBranchName(String refName) {
+    return delegate.shortenRemoteBranchName(refName);
+  }
+
+  @Override
+  public void setGitwebDescription(String description) throws IOException {
+    delegate.setGitwebDescription(description);
+  }
+
+  @Override
+  public String readMergeCommitMsg() throws IOException, NoWorkTreeException {
+    return delegate.readMergeCommitMsg();
+  }
+
+  @Override
+  public void writeMergeCommitMsg(String msg) throws IOException {
+    delegate.writeMergeCommitMsg(msg);
+  }
+
+  @Override
+  public String readCommitEditMsg() throws IOException, NoWorkTreeException {
+    return delegate.readCommitEditMsg();
+  }
+
+  @Override
+  public void writeCommitEditMsg(String msg) throws IOException {
+    delegate.writeCommitEditMsg(msg);
+  }
+
+  @Override
+  public List<ObjectId> readMergeHeads() throws IOException, NoWorkTreeException {
+    return delegate.readMergeHeads();
+  }
+
+  @Override
+  public void writeMergeHeads(List<? extends ObjectId> heads) throws IOException {
+    delegate.writeMergeHeads(heads);
+  }
+
+  @Override
+  public ObjectId readCherryPickHead() throws IOException, NoWorkTreeException {
+    return delegate.readCherryPickHead();
+  }
+
+  @Override
+  public ObjectId readRevertHead() throws IOException, NoWorkTreeException {
+    return delegate.readRevertHead();
+  }
+
+  @Override
+  public void writeCherryPickHead(ObjectId head) throws IOException {
+    delegate.writeCherryPickHead(head);
+  }
+
+  @Override
+  public void writeRevertHead(ObjectId head) throws IOException {
+    delegate.writeRevertHead(head);
+  }
+
+  @Override
+  public void writeOrigHead(ObjectId head) throws IOException {
+    delegate.writeOrigHead(head);
+  }
+
+  @Override
+  public ObjectId readOrigHead() throws IOException, NoWorkTreeException {
+    return delegate.readOrigHead();
+  }
+
+  @Override
+  public String readSquashCommitMsg() throws IOException {
+    return delegate.readSquashCommitMsg();
+  }
+
+  @Override
+  public void writeSquashCommitMsg(String msg) throws IOException {
+    delegate.writeSquashCommitMsg(msg);
+  }
+
+  @Override
+  public List<RebaseTodoLine> readRebaseTodo(String path, boolean includeComments)
+      throws IOException {
+    return delegate.readRebaseTodo(path, includeComments);
+  }
+
+  @Override
+  public void writeRebaseTodoFile(String path, List<RebaseTodoLine> steps, boolean append)
+      throws IOException {
+    delegate.writeRebaseTodoFile(path, steps, append);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 69db066..4347f8f 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -1590,7 +1590,7 @@
         name = "--label",
         aliases = {"-l"},
         metaVar = "LABEL+VALUE",
-        usage = "label(s) to assign (defaults to +1 if no value provided")
+        usage = "label(s) to assign (defaults to +1 if no value provided)")
     void addLabel(String token) throws CmdLineException {
       LabelVote v = LabelVote.parse(token);
       try {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 40c0477..370bc75 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
@@ -33,15 +34,20 @@
 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.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
+import com.google.gerrit.server.change.PluginDefinedInfosFactory;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -52,11 +58,13 @@
  * holding on to a single instance.
  */
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
-    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider {
+    implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
   private final Provider<CurrentUser> userProvider;
   private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+  private final List<Extension<ChangePluginDefinedInfoFactory>>
+      changePluginDefinedInfoFactoriesByPlugin = new ArrayList<>();
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -74,7 +82,8 @@
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
       DynamicSet<ChangeAttributeFactory> attributeFactories,
-      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
+      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
+      DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
         metricMaker,
         ChangeSchemaDefinitions.INSTANCE,
@@ -88,10 +97,15 @@
 
     ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
         ImmutableListMultimap.builder();
+    ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
+        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.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
     attributeFactoriesByPlugin = factoriesBuilder.build();
+    changePluginDefinedInfoFactories
+        .entries()
+        .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
   }
 
   @Override
@@ -128,6 +142,17 @@
             .map(e -> new Extension<>(e.getKey(), e::getValue)));
   }
 
+  public PluginDefinedInfosFactory getInfosFactory() {
+    return this::createPluginDefinedInfos;
+  }
+
+  @Override
+  public ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds) {
+    return PluginDefinedAttributesFactories.createAll(
+        cds, this, changePluginDefinedInfoFactoriesByPlugin.stream());
+  }
+
   @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 02e8434..b931457 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -17,11 +17,14 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.DynamicOptions;
@@ -97,6 +100,8 @@
 
   private OutputStream outputStream = DisabledOutputStream.INSTANCE;
   private PrintWriter out;
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+      ImmutableListMultimap.of();
 
   @Inject
   OutputStreamQuery(
@@ -207,6 +212,7 @@
         Map<Project.NameKey, Repository> repos = new HashMap<>();
         Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
         QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
+        pluginInfosByChange = queryProcessor.createPluginDefinedInfos(results.entities());
         try {
           for (ChangeData d : results.entities()) {
             show(buildChangeAttribute(d, repos, revWalks));
@@ -325,6 +331,15 @@
     }
 
     c.plugins = queryProcessor.getAttributesFactory().create(d);
+    List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
+    if (!pluginInfos.isEmpty()) {
+      if (c.plugins == null) {
+        c.plugins = pluginInfos;
+      } else {
+        c.plugins = new ArrayList<>(c.plugins);
+        c.plugins.addAll(pluginInfos);
+      }
+    }
     return c;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index c28741b..1ef3c4b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -27,11 +29,13 @@
 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.ChangePluginDefinedInfoFactory;
 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.inject.Inject;
+import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -43,6 +47,7 @@
         DynamicOptions.BeanProvider {
   private final ChangeJson.Factory json;
   private final DynamicSet<ChangeAttributeFactory> attrFactories;
+  private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
   private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
 
@@ -57,9 +62,13 @@
   }
 
   @Inject
-  GetChange(ChangeJson.Factory json, DynamicSet<ChangeAttributeFactory> attrFactories) {
+  GetChange(
+      ChangeJson.Factory json,
+      DynamicSet<ChangeAttributeFactory> attrFactories,
+      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
     this.json = json;
     this.attrFactories = attrFactories;
+    this.pdiFactories = pdiFactories;
   }
 
   @Override
@@ -82,11 +91,17 @@
   }
 
   private ChangeJson newChangeJson() {
-    return json.create(options, this::buildPluginInfo);
+    return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
   }
 
   private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
     return PluginDefinedAttributesFactories.createAll(
         cd, this, Streams.stream(attrFactories.entries()));
   }
+
+  private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+      Collection<ChangeData> cds) {
+    return PluginDefinedAttributesFactories.createAll(
+        cds, this, Streams.stream(pdiFactories.entries()));
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 878e714..0fec476 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -165,7 +165,9 @@
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
     List<List<ChangeInfo>> res =
-        json.create(options, queryProcessor.getAttributesFactory()).format(results);
+        json.create(
+                options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
+            .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/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index d5089ff..31198d5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.server.change.ChangeAttributeFactory;
 import com.google.inject.AbstractModule;
+import java.util.Arrays;
 import org.junit.Test;
 
 @NoHttpd
@@ -50,6 +51,18 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
+  }
+
+  @Test
+  public void getSingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.toString()).get())));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
@@ -65,6 +78,53 @@
         (id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
   }
 
+  @Test
+  public void queryChangeWithOptionBulkAttribute() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
+        (id, opts) ->
+            pluginInfosFromChangeInfos(
+                gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
+  }
+
+  @Test
+  public void getChangeWithOptionBulkAttribute() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())),
+        (id, opts) ->
+            pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get(opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
+  }
+
   static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
     @Override
     public void configure() {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 7649316..17bf37e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import org.junit.Test;
@@ -68,6 +70,24 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeWithSimpleAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeDetailWithSimpleAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+  }
+
+  @Test
   public void queryChangeWithOption() throws Exception {
     getChangeWithOption(
         id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
@@ -88,6 +108,57 @@
         (id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
   }
 
+  @Test
+  public void pluginDefinedQueryChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
+        (id, opts) -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id, opts))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))),
+        (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
+  }
+
+  @Test
+  public void pluginDefinedGetChangeDetailWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
+        (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+  }
+
   private String changeQueryUrl(Change.Id id) {
     return changeQueryUrl(id, ImmutableListMultimap.of());
   }
@@ -133,7 +204,8 @@
   }
 
   @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
+  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
+      throws Exception {
     res.assertOK();
     List<Map<String, Object>> changeInfos =
         GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
@@ -142,10 +214,28 @@
   }
 
   @Nullable
-  private List<MyInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
+  private List<PluginDefinedInfo> 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"));
   }
+
+  @Nullable
+  private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
+      throws Exception {
+    res.assertOK();
+    Map<String, Object> changeInfo =
+        GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+    return getPluginInfosFromChangeInfos(GSON, Arrays.asList(changeInfo));
+  }
+
+  @Nullable
+  private Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(RestResponse res)
+      throws Exception {
+    res.assertOK();
+    List<Map<String, Object>> changeInfos =
+        GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+    return getPluginInfosFromChangeInfos(GSON, changeInfos);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 4bf7c19..38293f9 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.server.query.change.OutputStreamQuery;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
@@ -52,12 +53,55 @@
   }
 
   @Test
+  public void querySingleChangeWithBulkAttribute() throws Exception {
+    getSingleChangeWithPluginDefinedBulkAttribute(
+        id -> pluginInfosFromList(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))));
   }
 
+  @Test
+  public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeOption(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
+        (id, opts) -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id, opts))));
+  }
+
+  @Test
+  public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+    getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+    getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+        () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+  }
+
+  @Test
+  public void getChangeWithPluginDefinedException() throws Exception {
+    getChangeWithPluginDefinedBulkAttributeWithException(
+        id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
+  }
+
   private String changeQueryCmd(Change.Id id) {
     return changeQueryCmd(id, ImmutableListMultimap.of());
   }
@@ -72,7 +116,22 @@
   }
 
   @Nullable
-  private static List<MyInfo> pluginInfoFromSingletonList(String sshOutput) throws Exception {
+  private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
+      throws Exception {
+    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+
+    assertThat(changeAttrs).hasSize(1);
+    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+  }
+
+  @Nullable
+  private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
+      throws Exception {
+    List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+    return getPluginInfosFromChangeInfos(GSON, changeAttrs);
+  }
+
+  private static List<Map<String, Object>> getChangeAttrs(String sshOutput) throws Exception {
     List<Map<String, Object>> changeAttrs = new ArrayList<>();
     for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
       Map<String, Object> changeAttr =
@@ -81,8 +140,6 @@
         changeAttrs.add(changeAttr);
       }
     }
-
-    assertThat(changeAttrs).hasSize(1);
-    return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+    return changeAttrs;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index b888102..00d01d6 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -38,7 +38,9 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -57,6 +59,7 @@
 public class ProjectOperationsImplTest extends AbstractDaemonTest {
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupsOperations;
 
   @Test
   public void defaultName() throws Exception {
@@ -122,6 +125,13 @@
   }
 
   @Test
+  public void createWithOwners() throws Exception {
+    AccountGroup.UUID uuid = groupsOperations.newGroup().create();
+    Project.NameKey key = projectOperations.newProject().addOwner(uuid).create();
+    assertPermissions(key, groupRef(uuid), "refs/*", false, Permission.OWNER);
+  }
+
+  @Test
   public void getProjectConfig() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index d6a3381..00e5794 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit d6a33818440eb20aca64a761f79652525b3eb060
+Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
diff --git a/plugins/replication b/plugins/replication
index bc47d23..5ddc976 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit bc47d238733981d957a820f4c66f7128fca80663
+Subproject commit 5ddc976586c8da17fd93e7b3d3ee54122421ec21