Expose the CopyApprovals site program as a command

Allow Gerrit admins to copy the approval labels on running
server by exposing the functionality as a Gerrit SSH command.

The copy-approvals command is limited to the Gerrit admin
because may potentially cause a significant load on the server
and generate a substantial (e.g. hundreds of thousands) ref-update
events.

NOTE: This command is going to be dropped in v3.6 because the
logic for calculating the inferred labels has been removed.

Release-Notes: Introduce copy-approvals SSH command
Change-Id: I13f469b950e2de0b366f28e089cb114781dbdb11
diff --git a/Documentation/cmd-copy-approvals.txt b/Documentation/cmd-copy-approvals.txt
new file mode 100644
index 0000000..ba5344f
--- /dev/null
+++ b/Documentation/cmd-copy-approvals.txt
@@ -0,0 +1,60 @@
+= gerrit copy-approvals
+
+== NAME
+gerrit copy-approvals - Copy all inferred approvals labels to the latest patch-set.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit copy-approvals_
+  [--verbose | -v]
+  [PROJECT]...
+--
+
+== DESCRIPTION
+Gerrit has historically computed votes using an inference algorithm that
+was cumulating them from all the patch-sets. That was not efficient since
+it had to take into account copied votes from very old patchsets.
+E.g, votes sometimes need to be copied from ps1 to ps10.
+
+Gerrit copy the approvals from the inferred votes to the latest patch-sets
+once a change receives a new label update.
+
+The copy-approval command scans all the changes of a project and looks for
+all votes that have not been copied yet, calculate the inferred score and
+apply that as copied label to the latest patch-set.
+
+NOTE: The label copied as part of this process receives the grant date of
+the timestamp of the copy-approval command execution, not the one associated
+with the inferred vote.
+
+== OPTIONS
+
+--verbose::
+-v::
+	Display projects/changes impacted by the label copy operation.
+
+== ACCESS
+Only the user with MAINTAIN_SERVER permissions can run this command.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== EXAMPLES
+
+Copy all inferred labels on the project 'foo'
+----
+$ ssh -p 29418 review.example.com gerrit copy-approvals foo
+----
+
+Copy all inferred labels on all projects
+----
+$ ssh -p 29418 review.example.com gerrit copy-approvals
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java b/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
index 53c2241..87df465 100644
--- a/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -30,6 +31,7 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.function.Consumer;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -56,11 +58,11 @@
   public void persist()
       throws UpdateException, RestApiException, RepositoryNotFoundException, IOException {
     for (Project.NameKey project : repositoryManager.list()) {
-      persist(project);
+      persist(project, null);
     }
   }
 
-  public void persist(Project.NameKey project)
+  public void persist(Project.NameKey project, @Nullable Consumer<Change> labelsCopiedListener)
       throws IOException, UpdateException, RestApiException, RepositoryNotFoundException {
     try (BatchUpdate bu =
             batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs());
@@ -70,7 +72,7 @@
               .filter(r -> r.getName().endsWith(RefNames.META_SUFFIX))
               .collect(toImmutableList())) {
         Change.Id changeId = Change.Id.fromRef(changeMetaRef.getName());
-        bu.addOp(changeId, new PersistCopiedVotesOp(approvalsUtil));
+        bu.addOp(changeId, new PersistCopiedVotesOp(approvalsUtil, labelsCopiedListener));
       }
       bu.execute();
     }
@@ -81,28 +83,39 @@
     try (BatchUpdate bu =
         batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs())) {
       Change.Id changeId = change.getId();
-      bu.addOp(changeId, new PersistCopiedVotesOp(approvalsUtil));
+      bu.addOp(changeId, new PersistCopiedVotesOp(approvalsUtil, null));
       bu.execute();
     }
   }
 
   private static class PersistCopiedVotesOp implements BatchUpdateOp {
     private final ApprovalsUtil approvalsUtil;
+    private final Consumer<Change> listener;
 
-    PersistCopiedVotesOp(ApprovalsUtil approvalsUtil) {
+    PersistCopiedVotesOp(
+        ApprovalsUtil approvalsUtil, @Nullable Consumer<Change> labelsCopiedListener) {
       this.approvalsUtil = approvalsUtil;
+      this.listener = labelsCopiedListener;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws IOException {
-      ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+      Change change = ctx.getChange();
+      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
       approvalsUtil.persistCopiedApprovals(
           ctx.getNotes(),
           ctx.getNotes().getCurrentPatchSet(),
           ctx.getRevWalk(),
           ctx.getRepoView().getConfig(),
           update);
-      return update.hasCopiedApprovals();
+
+      boolean labelsCopied = update.hasCopiedApprovals();
+
+      if (labelsCopied && listener != null) {
+        listener.accept(change);
+      }
+
+      return labelsCopied;
     }
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java b/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java
new file mode 100644
index 0000000..eacec28
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.approval.RecursiveApprovalCopier;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+    name = "copy-approvals",
+    description = "Copy inferred approvals labels to the latest patch-set")
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+public class CopyApprovalsCommand extends SshCommand {
+
+  private final Set<Project.NameKey> projects = new HashSet<>();
+  private final RecursiveApprovalCopier recursiveApprovalCopier;
+  private final GitRepositoryManager repositoryManager;
+
+  @Argument(
+      index = 0,
+      required = false,
+      multiValued = true,
+      metaVar = "PROJECT",
+      usage = "list of projects to scan for approvals (default: all projects)")
+  void addProject(String project) {
+    projects.add(Project.nameKey(project));
+  }
+
+  @Option(
+      name = "--verbose",
+      aliases = "-v",
+      usage = "display projects/changes impacted by the label copy operation",
+      metaVar = "VERBOSE")
+  private boolean verbose;
+
+  @Inject
+  public CopyApprovalsCommand(
+      RecursiveApprovalCopier recursiveApprovalCopier, GitRepositoryManager repositoryManager) {
+    this.recursiveApprovalCopier = recursiveApprovalCopier;
+    this.repositoryManager = repositoryManager;
+  }
+
+  @Override
+  protected void run() throws Exception {
+    AtomicInteger changesCounter = new AtomicInteger();
+    stdout.println(
+        "Copying inferred approvals labels on " + (projects.isEmpty() ? "all projects" : projects));
+
+    Set<Project.NameKey> projectsList = projects.isEmpty() ? repositoryManager.list() : projects;
+
+    for (Project.NameKey project : projectsList) {
+      stdout.print("> " + project + " : ");
+      recursiveApprovalCopier.persist(
+          project,
+          c -> {
+            if (verbose) {
+              stdout.println("  [" + c.getProject() + "," + c.getChangeId() + "] updated");
+            }
+            changesCounter.incrementAndGet();
+          });
+      stdout.println("DONE");
+    }
+
+    stdout.println(
+        "Labels copied for "
+            + projectsList.size()
+            + " project(s) have impacted "
+            + changesCounter.get()
+            + " change(s)");
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index e7fe22f..1a08c43 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -109,6 +109,7 @@
 
     command(gerrit, RenameGroupCommand.class);
     command(gerrit, ReviewCommand.class);
+    command(gerrit, CopyApprovalsCommand.class);
     command(gerrit, SetProjectCommand.class);
     command(gerrit, SetReviewersCommand.class);
     command(gerrit, SetTopicCommand.class);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
index 4b8862e..90ee13e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
@@ -147,7 +147,7 @@
       u.save();
     }
 
-    recursiveApprovalCopier.persist(project);
+    recursiveApprovalCopier.persist(project, null);
 
     for (PushOneCommit.Result change : changes) {
       ApprovalInfo vote1 =
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 7224e19..469630f 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -63,6 +63,7 @@
   private static final ImmutableList<String> MASTER_ONLY_ROOT_COMMANDS =
       ImmutableList.of(
           "ban-commit",
+          "copy-approvals",
           "create-account",
           "create-branch",
           "create-group",