Allow admins to index a change even if the branch is not Readable for them

The ChangesCollection rejects the request if the current user doesn't have the
Read permission on the loaded change. Therefore, an attempt to reindex a change:

  PUT /changes/myProject~12345/index

gets rejected even before it reaches the indexing code.

On the other side, the admin can reindex all changes of that project and thus
also index the change 12345:

  POST /projects/myProject/index.changes

Indexing all changes of a project in order to just index one change is too much.
Add a new REST endpoint where a set of change-ids to index is given in the
request body.

  POST /config/server/index.changes
  Content-Type: application/json; charset=UTF-8

  {changes: ["foo~101", "bar~202"]}

Bug: issue 11205
Change-Id: I05fcc5a024c7634a969a7baac9fd61f3d0f3d12a
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 3ec989e..7be8c32 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1376,6 +1376,28 @@
   }
 ----
 
+[[index.changes]]
+=== Index a set of changes
+
+This endpoint allows Gerrit admins to index a set of changes with one request
+by providing a link:#index-changes-input[IndexChangesInput] entity.
+
+Using this endpoint Gerrit admins can also index change(s) which are not visible to them.
+
+.Request
+----
+  POST /config/server/index.changes HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {changes: ["foo~101", "bar~202"]}
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+----
+
 
 [[ids]]
 == IDs
@@ -1825,6 +1847,17 @@
 Only set for disk caches.
 |==================================
 
+[[index-changes-input]]
+=== IndexChangesInput
+The `IndexChangesInput` contains a list of numerical changes IDs to index.
+
+[options="header",cols="1,^2,4"]
+|================================
+|Field Name         ||Description
+|`changes`   ||
+List of link:rest-api-changes.html#change-id[change-ids]
+|================================
+
 [[jvm-summary-info]]
 === JvmSummaryInfo
 The `JvmSummaryInfo` entity contains information about the JVM.
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 286b045..27ed603 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -37,7 +37,7 @@
     countsByChange.clear();
   }
 
-  long getCount(ChangeInfo info) {
+  public long getCount(ChangeInfo info) {
     return countsByChange.get(info._number);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/IndexChanges.java b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
new file mode 100644
index 0000000..b178118
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
@@ -0,0 +1,83 @@
+// 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.restapi.config;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.restapi.config.IndexChanges.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class IndexChanges implements RestModifyView<ConfigResource, Input> {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class Input {
+    public Set<String> changes;
+  }
+
+  private final ChangeFinder changeFinder;
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeIndexer indexer;
+
+  @Inject
+  IndexChanges(
+      ChangeFinder changeFinder,
+      SchemaFactory<ReviewDb> schemaFactory,
+      ChangeData.Factory changeDataFactory,
+      ChangeIndexer indexer) {
+    this.changeFinder = changeFinder;
+    this.schemaFactory = schemaFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.indexer = indexer;
+  }
+
+  @Override
+  public Object apply(ConfigResource resource, Input input) throws OrmException {
+    if (input == null || input.changes == null) {
+      return Response.ok("Nothing to index");
+    }
+
+    try (ReviewDb db = schemaFactory.open()) {
+      for (String id : input.changes) {
+        for (ChangeNotes n : changeFinder.find(id)) {
+          try {
+            indexer.index(changeDataFactory.create(db, n));
+            logger.atFine().log("Indexed change %s", id);
+          } catch (IOException e) {
+            logger.atSevere().withCause(e).log("Failed to index change %s", id);
+          }
+        }
+      }
+    }
+
+    return Response.ok("Indexed changes " + input.changes);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/Module.java b/java/com/google/gerrit/server/restapi/config/Module.java
index c4a6f56..78a7f48 100644
--- a/java/com/google/gerrit/server/restapi/config/Module.java
+++ b/java/com/google/gerrit/server/restapi/config/Module.java
@@ -37,6 +37,7 @@
     get(CONFIG_KIND, "version").to(GetVersion.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
     post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    post(CONFIG_KIND, "index.changes").to(IndexChanges.class);
     post(CONFIG_KIND, "reload").to(ReloadConfig.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
diff --git a/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
index 4a2c81b..2e5ea00 100644
--- a/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/ConfigRestApiBindingsIT.java
@@ -57,7 +57,8 @@
           RestCall.get("/config/server/capabilities"),
           RestCall.get("/config/server/caches"),
           RestCall.post("/config/server/caches"),
-          RestCall.get("/config/server/tasks"));
+          RestCall.get("/config/server/tasks"),
+          RestCall.post("/config/server/index.changes"));
 
   /**
    * Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
new file mode 100644
index 0000000..fa35d19
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
@@ -0,0 +1,98 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ChangeIndexedCounter;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.restapi.config.IndexChanges;
+import com.google.inject.Inject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class IndexChangesIT extends AbstractDaemonTest {
+
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+
+  private ChangeIndexedCounter changeIndexedCounter;
+  private RegistrationHandle changeIndexedCounterHandle;
+
+  @Before
+  public void addChangeIndexedCounter() {
+    changeIndexedCounter = new ChangeIndexedCounter();
+    changeIndexedCounterHandle = changeIndexedListeners.add("gerrit", changeIndexedCounter);
+  }
+
+  @After
+  public void removeChangeIndexedCounter() {
+    if (changeIndexedCounterHandle != null) {
+      changeIndexedCounterHandle.remove();
+    }
+  }
+
+  @Test
+  public void indexRequestFromNonAdminRejected() throws Exception {
+    String changeId = createChange().getChangeId();
+    IndexChanges.Input in = new IndexChanges.Input();
+    in.changes = ImmutableSet.of(changeId);
+    changeIndexedCounter.clear();
+    userRestSession.post("/config/server/index.changes", in).assertForbidden();
+    assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(0);
+  }
+
+  @Test
+  public void indexVisibleChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    IndexChanges.Input in = new IndexChanges.Input();
+    in.changes = ImmutableSet.of(changeId);
+    changeIndexedCounter.clear();
+    adminRestSession.post("/config/server/index.changes", in).assertOK();
+    assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
+  }
+
+  @Test
+  public void indexNonVisibleChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    ChangeInfo changeInfo = info(changeId);
+    blockRead("refs/heads/master");
+    IndexChanges.Input in = new IndexChanges.Input();
+    changeIndexedCounter.clear();
+    in.changes = ImmutableSet.of(changeId);
+    adminRestSession.post("/config/server/index.changes", in).assertOK();
+    assertThat(changeIndexedCounter.getCount(changeInfo)).isEqualTo(1);
+  }
+
+  @Test
+  public void indexMultipleChanges() throws Exception {
+    ImmutableSet.Builder<String> changeIds = ImmutableSet.builder();
+    for (int i = 0; i < 10; i++) {
+      changeIds.add(createChange().getChangeId());
+    }
+    IndexChanges.Input in = new IndexChanges.Input();
+    in.changes = changeIds.build();
+    changeIndexedCounter.clear();
+    adminRestSession.post("/config/server/index.changes", in).assertOK();
+    for (String changeId : in.changes) {
+      assertThat(changeIndexedCounter.getCount(info(changeId))).isEqualTo(1);
+    }
+  }
+}