Add namespacing support for task quotas

Make it possible to specify quotas for a project namespace.
It utilizes the already existing syntax for `quota` section.

The project name is correctly decoded when the specific task
has it specified it in its `toString()` implementation. If a
project name was not found quotas from the global (`*`) section
are applied.

Change-Id: I81ede1412c5876b9f89f73e42557fc56532aa464
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java
index 59921c7..87e21fd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
+import java.util.List;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -32,6 +33,10 @@
 
   public QuotaSection firstMatching(Project.NameKey project) {
     Config cfg = projectCache.getAllProjects().getConfig("quota.config").get();
+    return firstMatching(cfg, project);
+  }
+
+  public QuotaSection firstMatching(Config cfg, Project.NameKey project) {
     Set<String> namespaces = cfg.getSubsections(QuotaSection.QUOTA);
     String p = project.get();
     for (String n : namespaces) {
@@ -56,8 +61,17 @@
     return null;
   }
 
-  public QuotaSection getGlobalNamespacedQuota() {
-    Config cfg = projectCache.getAllProjects().getConfig("quota.config").get();
+  public QuotaSection getGlobalNamespacedQuota(Config cfg) {
     return new QuotaSection(cfg, "*");
   }
+
+  public List<QuotaSection> getQuotaNamespaces(Config cfg) {
+    return cfg.getSubsections(QuotaSection.QUOTA).stream()
+        .map(ns -> new QuotaSection(cfg, ns))
+        .toList();
+  }
+
+  public Config getQuotaConfig() {
+    return projectCache.getAllProjects().getConfig("quota.config").get();
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
index 3ab706d..a1be14b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
@@ -22,33 +22,23 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class QuotaSection {
+public record QuotaSection(Config cfg, String namespace, String resolvedNamespace) {
   private static final Logger log = LoggerFactory.getLogger(QuotaSection.class);
   public static final String QUOTA = "quota";
   public static final String KEY_MAX_PROJECTS = "maxProjects";
   public static final String KEY_MAX_REPO_SIZE = "maxRepoSize";
   public static final String KEY_MAX_TOTAL_SIZE = "maxTotalSize";
 
-  private final Config cfg;
-  private final String namespace;
-  private final Namespace resolvedNamespace;
-
-  QuotaSection(Config cfg, String namespace) {
+  public QuotaSection(Config cfg, String namespace) {
     this(cfg, namespace, namespace);
   }
 
-  QuotaSection(Config cfg, String namespace, String resolvedNamespace) {
-    this.cfg = cfg;
-    this.namespace = namespace;
-    this.resolvedNamespace = new Namespace(resolvedNamespace);
-  }
-
   public String getNamespace() {
-    return resolvedNamespace.get();
+    return resolvedNamespace;
   }
 
   public boolean matches(Project.NameKey project) {
-    return resolvedNamespace.matches(project);
+    return new Namespace(resolvedNamespace).matches(project);
   }
 
   public Integer getMaxProjects() {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
index b59d6f8..73c0552 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
@@ -14,14 +14,17 @@
 
 package com.googlesource.gerrit.plugins.quota;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.WorkQueue;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
+import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import javax.inject.Inject;
 import javax.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -29,21 +32,38 @@
 public class TaskQuotas implements WorkQueue.TaskParker {
   private static final Logger log = LoggerFactory.getLogger(TaskQuotas.class);
   private final QuotaFinder quotaFinder;
-  private final List<TaskQuota> quotas = new ArrayList<>();
   private final Map<Integer, List<TaskQuota>> quotasByTask = new ConcurrentHashMap<>();
+  private final Map<QuotaSection, List<TaskQuota>> quotasByNamespace = new HashMap<>();
+  private final Pattern PROJECT_PATTERN = Pattern.compile("\\s+(.*\\.git)\\s+(\\S+)$");
+  private final Config quotaConfig;
 
   @Inject
   public TaskQuotas(QuotaFinder quotaFinder) {
     this.quotaFinder = quotaFinder;
+    this.quotaConfig = quotaFinder.getQuotaConfig();
     initQuotas();
   }
 
   private void initQuotas() {
-    quotas.addAll(quotaFinder.getGlobalNamespacedQuota().getAllQuotas());
+    quotasByNamespace.putAll(
+        quotaFinder.getQuotaNamespaces(quotaConfig).stream()
+            .collect(Collectors.toMap(Function.identity(), QuotaSection::getAllQuotas)));
   }
 
   @Override
   public boolean isReadyToStart(WorkQueue.Task<?> task) {
+    Optional<Project.NameKey> estimatedProject = estimateProject(task);
+    List<TaskQuota> quotas =
+        estimatedProject
+            .map(
+                project -> {
+                  return quotasByNamespace.getOrDefault(
+                      Optional.ofNullable(quotaFinder.firstMatching(quotaConfig, project))
+                          .orElse(quotaFinder.getGlobalNamespacedQuota(quotaConfig)),
+                      List.of());
+                })
+            .orElse(List.of());
+
     List<TaskQuota> acquiredQuotas = new ArrayList<>();
     for (TaskQuota quota : quotas) {
       if (quota.isApplicable(task)) {
@@ -79,4 +99,10 @@
     Optional.ofNullable(quotasByTask.remove(task.getTaskId()))
         .ifPresent(quotas -> quotas.forEach(q -> q.release(task)));
   }
+
+  private Optional<Project.NameKey> estimateProject(WorkQueue.Task<?> task) {
+    Matcher matcher = PROJECT_PATTERN.matcher(task.toString());
+
+    return matcher.find() ? Optional.of(Project.nameKey(matcher.group(1))) : Optional.empty();
+  }
 }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index e12c482..da693fa 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -244,10 +244,8 @@
 -----------
 
 Task quotas provide fine-grained control over queues for administrators.
-Quotas should be specified in the global * section (namespacing support
-has not been implemented yet). Once the defined limit is reached, any
-additional tasks are parked, preventing them from consuming threads and
-allowing other tasks to continue execution.
+Once the defined limit is reached, any additional tasks are parked, preventing
+them from consuming threads and allowing other tasks to continue execution.
 
 The `maxStartForTaskForQueue` setting defines the maximum number of threads
 that can be started for a specific task and queue combination. Example: