Added plugin repo-repack-tracker

Added a groovy script plugin to check if a repack process is running
for a list of projects, exposing as a Gerrit metric for each project.

Change-Id: I940777e5503b05d0c070bed9fd72b72402999b31
diff --git a/admin/README.md b/admin/README.md
index 7c0ba9f..d798ab2 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -12,3 +12,4 @@
 * [readonly-1.0.groovy](/admin/readonly-1.0.groovy) - Set all Gerrit projects in read-only mode during maintenance
 * [stale-packed-refs-1.0.groovy](/admin/stale-packed-refs-1.0.groovy) - Check all specified projects and expose metric with age of `packed-refs.lock` files
 * [track-and-disable-inactive-users.groovy](/admin/track-and-disable-inactive-users.groovy) - Tracks users login in `track-active-users_cache` and automatically disables inactive users
+* [repo-repack-tracker-1.0.groovy](/admin/repo-repack-tracker-1.0.groovy) - Check if a repack process is running for a list of projects
diff --git a/admin/repo-repack-tracker-1.0.groovy b/admin/repo-repack-tracker-1.0.groovy
new file mode 100644
index 0000000..6a4553a
--- /dev/null
+++ b/admin/repo-repack-tracker-1.0.groovy
@@ -0,0 +1,142 @@
+// Copyright (C) 2025 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.
+
+import com.google.common.flogger.FluentLogger
+import com.google.gerrit.entities.Project
+import com.google.gerrit.extensions.annotations.*
+import com.google.gerrit.extensions.events.LifecycleListener
+import com.google.gerrit.lifecycle.LifecycleModule
+import com.google.gerrit.metrics.*
+import com.google.gerrit.server.config.*
+import com.google.gerrit.server.git.LocalDiskRepositoryManager
+import com.google.inject.Singleton
+import org.eclipse.jgit.lib.Constants
+
+import javax.inject.Inject
+import java.util.concurrent.TimeUnit
+
+import static groovy.io.FileType.FILES
+
+@Singleton
+@Listen
+class RepoRepackTracker implements LifecycleListener {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass()
+  private static final NAME = "is_repack_running_per_project"
+  private static final DESCRIPTION = "Check repack running for the project"
+  private static final GIT_PACK_FOLDER = "objects/pack"
+  private static final TMP_PREFIX = "tmp_"
+  private static final TMP_SUFFIX = ".tmp"
+
+  @Inject
+  @PluginName
+  String pluginName
+  @Inject
+  PluginConfigFactory configFactory
+  @Inject
+  MetricMaker metrics
+  @Inject
+  LocalDiskRepositoryManager repoMgr
+
+  private def tmpFilter = ~/^(${TMP_PREFIX}.*|.*${TMP_SUFFIX})$/
+  private long considerStaleAfterMs
+  CallbackMetric1<String, Long> projectsAndGcMetric
+  List<String> projects
+
+  @Override
+  void start() {
+    PluginConfig pluginConfig = configFactory.getFromGerritConfig(pluginName)
+    long considerStaleAfter = ConfigUtil.getTimeUnit(
+        pluginConfig.getString("considerStaleAfter", "60 minutes"),
+        60L,
+        TimeUnit.MINUTES)
+    considerStaleAfterMs = TimeUnit.MILLISECONDS.convert(considerStaleAfter, TimeUnit.MINUTES)
+
+    projects = pluginConfig.getStringList("project")
+    projectsAndGcMetric = createCallbackMetric(NAME, DESCRIPTION)
+    addMetricsTrigger(projectsAndGcMetric, projects)
+
+    logger.atInfo().log("Plugin %s started (staleAfter %d minutes)",
+        pluginName,
+        considerStaleAfter)
+  }
+
+  CallbackMetric1<String, Long> createCallbackMetric(String name, String description) {
+    metrics.newCallbackMetric(
+        name,
+        Long.class,
+        new Description(description).setGauge(),
+        Field.ofProjectName("repository_name")
+            .description(description)
+            .build()
+    )
+  }
+
+  void addMetricsTrigger(CallbackMetric1<String, Long> projectsAndGcMetric, List<String> projects) {
+    metrics.newTrigger(
+        projectsAndGcMetric, {
+      if (projects.isEmpty()) {
+        projectsAndGcMetric.forceCreate("")
+      } else {
+        projects.each { e ->
+          projectsAndGcMetric.set(e, checkRepackingRunningForProject(e))
+        }
+        projectsAndGcMetric.prune()
+      }
+    })
+  }
+
+  long checkRepackingRunningForProject(String projectName) {
+    def isRepackRunning = 0L
+    try {
+      def repoDir = getRepoDir(projectName)
+      def packDir = new File(repoDir, GIT_PACK_FOLDER)
+      if (packDir.exists() && hasRepackTmpFiles(packDir)) {
+        isRepackRunning = 1L
+      }
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Could not check project %s",  projectName)
+    }
+    isRepackRunning
+  }
+
+  File getRepoDir(String projectName) {
+    def name = Project.nameKey(projectName)
+    def path = repoMgr.getBasePath(name)
+    return path.resolve("${name.get()}${Constants.DOT_GIT_EXT}").toFile()
+  }
+
+  boolean hasRepackTmpFiles(folder) {
+    def modifiedCutoffTime = System.currentTimeMillis() - considerStaleAfterMs
+    def tmpFileFound = false
+
+    folder.traverse(type: FILES, nameFilter: tmpFilter) { file ->
+      if (file.lastModified() >= modifiedCutoffTime) {
+        tmpFileFound = true
+      }
+    }
+    return tmpFileFound
+  }
+
+  @Override
+  void stop() {}
+}
+
+class RepoRepackTrackerModule extends LifecycleModule {
+    protected void configure() {
+        listener().to(RepoRepackTracker)
+    }
+}
+
+modules = [RepoRepackTrackerModule]
diff --git a/admin/repo-repack-tracker.md b/admin/repo-repack-tracker.md
new file mode 100644
index 0000000..00f4b96
--- /dev/null
+++ b/admin/repo-repack-tracker.md
@@ -0,0 +1,47 @@
+Repo Repack Tracker
+==============================
+
+DESCRIPTION
+-----------
+Check for each project configured if a repack  process is running.
+
+Configuration
+=========================
+
+The repo-repack-tracker plugin is configured in
+$site_path/etc/gerrit.config` files, example:
+
+```text
+[plugins "repo-repack-tracker"]
+    considerStaleAfter = 1h
+    project = test
+```
+
+Configuration parameters
+---------------------
+
+=======
+```plugins.repo-repack-tracker.considerStaleAfter```
+:  If any of the files checked for determining if the repack is running has the modified date older than this value, then
+the repack is considered stale (not running). If a time unit suffix is not specified, `minutes` is assumed.
+
+Default: 1 hour.
+
+```plugins.repo-repack-tracker.project```
+:  The name of the repository to check.
+   May be specified more than once to specify multiple projects, for example:
+
+   ```
+   project = foo
+   project = bar
+   ```
+
+Metrics
+---------------------
+Currently, the metrics exposed are the following:
+
+```groovy_repo_gc_tracker_is_repack_running_per_project_<repo_name>```
+:  Indicates if the repack is currently running for the <repo_name>.
+The <repo_name> is sanitised to prevent the introduction of invalid characters for a metric name and to remove
+the risk of collisions (between the sanitized metric names).
+Repack is considered running when its value is greater than 0 .