Add numberofprojects metric

Implement new metric plugins_git_repo_metrics_numberofprojects which
captures the number of projects at collection moment.
The plugin reload mode is changed to restart to allow for the new metric
to reload, as due to the order of the events on the plugin reload
mechanism, the previous version of the plugin is maintained until the
last stage, which invalidates the re-creation of the metric.

Bug: Issue 325029893
Change-Id: I86e8a1f7f35be650ebd6e24ad5ca92723fb89d3d
diff --git a/BUILD b/BUILD
index 7370013..2c38d9e 100644
--- a/BUILD
+++ b/BUILD
@@ -15,6 +15,7 @@
     manifest_entries = [
         "Gerrit-PluginName: git-repo-metrics",
         "Gerrit-Module: com.googlesource.gerrit.plugins.gitrepometrics.Module",
+        "Gerrit-ReloadMode: restart",
         "Implementation-Title: git-repo-metrics plugin",
         "Implementation-URL: https://review.gerrithub.io/admin/repos/GerritForge/git-repo-metrics",
         "Implementation-Vendor: GerritForge",
diff --git a/src/main/java/com/googlesource/gerrit/plugins/gitrepometrics/Module.java b/src/main/java/com/googlesource/gerrit/plugins/gitrepometrics/Module.java
index 6bbf8f7..014a4d8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/gitrepometrics/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/gitrepometrics/Module.java
@@ -40,5 +40,6 @@
     DynamicSet.bind(binder(), MetricsCollector.class).to(FSMetricsCollector.class);
     DynamicSet.bind(binder(), MetricsCollector.class).to(GitRefsMetricsCollector.class);
     install(new UpdateGitMetricsTaskModule());
+    listener().to(RepoCountMetricRegister.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/gitrepometrics/RepoCountMetricRegister.java b/src/main/java/com/googlesource/gerrit/plugins/gitrepometrics/RepoCountMetricRegister.java
new file mode 100644
index 0000000..221dda7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/gitrepometrics/RepoCountMetricRegister.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.gitrepometrics;
+
+import javax.inject.Inject;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Supplier;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RepoCountMetricRegister implements LifecycleListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  protected static final String REPO_COUNT_METRIC_NAME = "numberofprojects";
+  private final MetricMaker metricMaker;
+  private final ProjectCache projectCache;
+
+  @VisibleForTesting
+  @Inject
+  RepoCountMetricRegister(ProjectCache projectCache, MetricMaker metricMaker) {
+    this.metricMaker = metricMaker;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public void start() {
+    logger.atInfo().log("Registering metric " + REPO_COUNT_METRIC_NAME);
+
+    metricMaker.newCallbackMetric(
+        REPO_COUNT_METRIC_NAME,
+        Long.class,
+        new Description("Number of existing projects.").setGauge().setUnit("Count"),
+        new Supplier<Long>() {
+          @Override
+          public Long get() {
+            return (long) projectCache.all().size();
+          }
+        });
+  }
+
+  @Override
+  public void stop() {}
+}
diff --git a/src/resources/Documentation/config.md b/src/resources/Documentation/config.md
index 803839a..8f8b6cd 100644
--- a/src/resources/Documentation/config.md
+++ b/src/resources/Documentation/config.md
@@ -2,9 +2,14 @@
 ======================
 
 The @PLUGIN@ allows a systematic collection of repository metrics.
-Metrics are updated upon a `ref-update` receive.
 
-Currently, the metrics exposed are the following:
+The following exposed metric is available on request with the current status:
+
+```bash
+plugins_git_repo_metrics_numberOfProjects
+```
+
+The following exposed metrics are updated upon a `ref-update` receive:
 
 ```bash
 plugins_git_repo_metrics_numberofbitmaps_<repo_name>
diff --git a/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/FakeMetricMaker.java b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/FakeMetricMaker.java
index 6faaf99..0eb1778 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/FakeMetricMaker.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/FakeMetricMaker.java
@@ -14,8 +14,13 @@
 
 package com.googlesource.gerrit.plugins.gitrepometrics;
 
+import java.util.HashMap;
+import java.util.Optional;
+
 import com.codahale.metrics.Meter;
 import com.codahale.metrics.MetricRegistry;
+import com.google.common.base.Supplier;
+import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.metrics.CallbackMetric0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -23,16 +28,19 @@
 class FakeMetricMaker extends DisabledMetricMaker {
   Integer callsCounter;
   private MetricRegistry metricRegistry;
+  HashMap<String, Supplier<?>> actionMap;
 
+  @SuppressWarnings({"rawtypes", "unchecked"})
   FakeMetricMaker(MetricRegistry metricRegistry) {
-    callsCounter = 0;
+    this.callsCounter = 0;
     this.metricRegistry = metricRegistry;
+    this.actionMap = new HashMap();
   }
 
+  @SuppressWarnings("unused")
   @Override
   public <V> CallbackMetric0<V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc) {
-
     callsCounter += 1;
     metricRegistry.register(
         String.format("%s/%s/%s", "plugins", "git-repo-metrics", name), new Meter());
@@ -45,4 +53,25 @@
       public void remove() {}
     };
   }
+
+  @Override
+  public <V> RegistrationHandle newCallbackMetric(
+      String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
+    callsCounter += 1;
+
+    String metricName = String.format("%s/%s/%s", "plugins", "git-repo-metrics", name);
+
+    metricRegistry.register(metricName, new Meter());
+
+    actionMap.put(metricName, trigger);
+
+    return null;
+  }
+
+  @SuppressWarnings("rawtypes")
+  public Optional<Supplier> getValueForMetric(String metric) {
+    if (actionMap.containsKey(metric)) return Optional.of(actionMap.get(metric));
+
+    return Optional.empty();
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/FakeProjectCache.java b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/FakeProjectCache.java
new file mode 100644
index 0000000..96d8db9
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/FakeProjectCache.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.gitrepometrics;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.server.project.NullProjectCache;
+
+public class FakeProjectCache extends NullProjectCache {
+  private Set<Project.NameKey> projects;
+
+  @Override
+  public ImmutableSortedSet<NameKey> all() {
+    return ImmutableSortedSet.copyOf(projects);
+  }
+
+  public void setProjectCount(int projectCount) {
+    projects =
+        IntStream.range(0, projectCount)
+            .mapToObj(i -> NameKey.parse(String.valueOf(i)))
+            .collect(Collectors.toSet());
+  }
+
+  public FakeProjectCache(int count) {
+    setProjectCount(count);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/GitRepoMetricsCacheIT.java b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/GitRepoMetricsCacheIT.java
index 4eee736..b72fb9c 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/GitRepoMetricsCacheIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/GitRepoMetricsCacheIT.java
@@ -37,7 +37,7 @@
     sysModule = "com.googlesource.gerrit.plugins.gitrepometrics.Module")
 public class GitRepoMetricsCacheIT extends LightweightPluginDaemonTest {
 
-  private final int MAX_WAIT_TIME_FOR_METRICS_SECS = 5;
+  private final int MAX_WAIT_TIME_FOR_METRICS_SECS = 10;
 
   @Inject MetricRegistry metricRegistry;
   private FSMetricsCollector fsMetricsCollector;
@@ -77,8 +77,10 @@
             + gitRefsMetricsCollector.availableMetrics().size();
 
     try {
+      // One additional is added for the numberofprojects metric
       WaitUtil.waitUntil(
-          () -> getPluginMetricsCount() == (long) availableProjects.size() * expectedMetricsCount,
+          () ->
+              getPluginMetricsCount() == (long) (availableProjects.size() * expectedMetricsCount) + 1,
           Duration.ofSeconds(MAX_WAIT_TIME_FOR_METRICS_SECS));
     } catch (InterruptedException e) {
       fail(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/RepoCountMetricTest.java b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/RepoCountMetricTest.java
new file mode 100644
index 0000000..bd84544
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/gitrepometrics/RepoCountMetricTest.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.gitrepometrics;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import com.codahale.metrics.MetricRegistry;
+
+import com.google.common.base.Supplier;
+
+public class RepoCountMetricTest {
+  private FakeMetricMaker fakeMetricMaker;
+  private MetricRegistry metricRegistry;
+  private FakeProjectCache fakeProjectCache;
+  private String repoCountMetricName;
+
+  @Before
+  public void setup() {
+    metricRegistry = new MetricRegistry();
+    fakeMetricMaker = new FakeMetricMaker(metricRegistry);
+    fakeProjectCache = new FakeProjectCache(0);
+    repoCountMetricName =
+        String.format(
+            "%s/%s/%s",
+            "plugins", "git-repo-metrics", RepoCountMetricRegister.REPO_COUNT_METRIC_NAME);
+  }
+
+  @Test
+  public void metricIsCorrectlyRegistered() {
+    RepoCountMetricRegister repoCountMetricRegister =
+        new RepoCountMetricRegister(fakeProjectCache, fakeMetricMaker);
+
+    repoCountMetricRegister.start();
+
+    assertTrue(metricRegistry.getMetrics().containsKey(repoCountMetricName));
+
+    metricRegistry.remove(repoCountMetricName);
+  }
+
+  @Test
+  public void metricIsUpdated() {
+    RepoCountMetricRegister repoCountMetricRegister =
+        new RepoCountMetricRegister(fakeProjectCache, fakeMetricMaker);
+
+    repoCountMetricRegister.start();
+
+    assertEquals(1, fakeMetricMaker.actionMap.size());
+
+    @SuppressWarnings("rawtypes")
+    Optional<Supplier> obj = fakeMetricMaker.getValueForMetric(repoCountMetricName);
+
+    assertTrue(!obj.isEmpty());
+    assertEquals(0, ((Long) obj.get().get()).longValue());
+
+    fakeProjectCache.setProjectCount(2);
+
+    assertEquals(2, ((Long) obj.get().get()).longValue());
+  }
+}