Extend `maxStartForTaskForQueue` to scope for specific user

In order to have more granular control over the task quotas this
chage introduces ability to specify the user for which we need
to limit concurrent running tasks.

Example Usage:
  [quota "*"]
    maxStartForTaskForUserForQueue = 20 uploadpack servicebot SSH-Interactive-Worker
    maxStartForTaskForUserForQueue = 10 uploadpack admin SSH-Batch-Worker

Change-Id: I2010a02bbdf7a3660089af667c8dce0a799d64b1
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 a755b50..3ab706d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaSection.java
@@ -18,7 +18,6 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
-import java.util.regex.Matcher;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -29,7 +28,6 @@
   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";
-  public static final String KEY_MAX_START_FOR_TASK_FOR_QUEUE = "maxStartForTaskForQueue";
 
   private final Config cfg;
   private final String namespace;
@@ -74,22 +72,13 @@
     return cfg.getLong(QUOTA, namespace, KEY_MAX_TOTAL_SIZE, Long.MAX_VALUE);
   }
 
-  public List<TaskQuotaForTaskForQueue> getMaxStartForTaskForQueue() {
-    String[] vals = cfg.getStringList(QUOTA, namespace, KEY_MAX_START_FOR_TASK_FOR_QUEUE);
-    return Arrays.stream(vals)
-        .map(
-            val -> {
-              Matcher matcher = TaskQuotaForTaskForQueue.CONFIG_PATTERN.matcher(val);
-              if (matcher.matches()) {
-                return Optional.of(
-                    new TaskQuotaForTaskForQueue(
-                        matcher.group(3), matcher.group(2), Integer.parseInt(matcher.group(1))));
-              } else {
-                log.error("Invalid configuration entry [{}]", val);
-                return Optional.<TaskQuotaForTaskForQueue>empty();
-              }
-            })
-        .flatMap(Optional::stream)
+  public List<TaskQuota> getAllQuotas() {
+    return Arrays.stream(TaskQuotaKeys.values())
+        .flatMap(
+            type ->
+                Arrays.stream(cfg.getStringList(QUOTA, namespace, type.key))
+                    .map(type.processor)
+                    .flatMap(Optional::stream))
         .toList();
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuota.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuota.java
new file mode 100644
index 0000000..884d8ab
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuota.java
@@ -0,0 +1,30 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.quota;
+
+import com.google.gerrit.server.git.WorkQueue;
+import java.util.concurrent.Semaphore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public abstract class TaskQuota extends Semaphore {
+  protected static final Logger log = LoggerFactory.getLogger(TaskQuota.class);
+
+  public TaskQuota(int permits) {
+    super(permits);
+  }
+
+  public abstract boolean isApplicable(WorkQueue.Task<?> task);
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTask.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTask.java
new file mode 100644
index 0000000..9844473
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTask.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2014 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.quota;
+
+import com.google.gerrit.server.git.WorkQueue;
+import java.util.Map;
+import java.util.Set;
+
+public abstract class TaskQuotaForTask extends TaskQuota {
+  protected static final Map<String, Set<String>> SUPPORTED_TASKS_BY_GROUP =
+      Map.of("uploadpack", Set.of("git-upload-pack"));
+  private final String taskGroup;
+
+  public TaskQuotaForTask(String taskGroup, int permits) {
+    super(permits);
+    this.taskGroup = taskGroup;
+  }
+
+  @Override
+  public boolean isApplicable(WorkQueue.Task<?> task) {
+    return SUPPORTED_TASKS_BY_GROUP.get(taskGroup).stream()
+        .anyMatch(t -> task.toString().startsWith(t));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java
index 47d2468..5dbff11 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java
@@ -15,35 +15,35 @@
 package com.googlesource.gerrit.plugins.quota;
 
 import com.google.gerrit.server.git.WorkQueue;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.Semaphore;
+import java.util.Optional;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-public class TaskQuotaForTaskForQueue extends Semaphore {
-  private static final Map<String, Set<String>> SUPPORTED_TASKS_BY_GROUP =
-          Map.of("uploadpack", Set.of("git-upload-pack"));
+public class TaskQuotaForTaskForQueue extends TaskQuotaForTask {
   public static final Pattern CONFIG_PATTERN =
       Pattern.compile(
-          "(\\d+)\\s+("
-              + String.join("|", TaskQuotaForTaskForQueue.supportedTasks())
-              + ")\\s+(.+)");
-  private final String taskGroup;
-  private final String queue;
+          "(\\d+)\\s+(" + String.join("|", SUPPORTED_TASKS_BY_GROUP.keySet()) + ")\\s+(.+)");
+  private final String queueName;
 
-  public TaskQuotaForTaskForQueue(String queue, String taskGroup, int maxStart) {
-    super(maxStart);
-    this.queue = queue;
-    this.taskGroup = taskGroup;
+  public TaskQuotaForTaskForQueue(String queueName, String taskGroup, int maxStart) {
+    super(taskGroup, maxStart);
+    this.queueName = queueName;
   }
 
+  @Override
   public boolean isApplicable(WorkQueue.Task<?> task) {
-    return task.getQueueName().equals(queue)
-        && SUPPORTED_TASKS_BY_GROUP.get(this.taskGroup).stream()
-            .anyMatch(t -> task.toString().startsWith(t));
+    return super.isApplicable(task) && task.getQueueName().equals(queueName);
   }
 
-  public static Set<String> supportedTasks() {
-    return SUPPORTED_TASKS_BY_GROUP.keySet();
+  public static Optional<TaskQuota> build(String config) {
+    Matcher matcher = TaskQuotaForTaskForQueue.CONFIG_PATTERN.matcher(config);
+    if (matcher.matches()) {
+      return Optional.of(
+          new TaskQuotaForTaskForQueue(
+              matcher.group(3), matcher.group(2), Integer.parseInt(matcher.group(1))));
+    } else {
+      log.error("Invalid configuration entry [{}]", config);
+      return Optional.empty();
+    }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueueForUser.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueueForUser.java
new file mode 100644
index 0000000..eb64ae5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueueForUser.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2014 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.quota;
+
+import com.google.gerrit.server.git.WorkQueue;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class TaskQuotaForTaskForQueueForUser extends TaskQuotaForTaskForQueue {
+  public static final Pattern CONFIG_PATTERN =
+      Pattern.compile(
+          "(\\d+)\\s+("
+              + String.join("|", SUPPORTED_TASKS_BY_GROUP.keySet())
+              + ")\\s+([a-zA-Z0-9]+)"
+              + "\\s+(.+)");
+  public static final Pattern USER_EXTRACT_PATTERN = Pattern.compile("\\(([a-z0-9]+)\\)$");
+  private final String user;
+
+  public TaskQuotaForTaskForQueueForUser(
+      String queueName, String user, String taskGroup, int maxStart) {
+    super(queueName, taskGroup, maxStart);
+    this.user = user;
+  }
+
+  @Override
+  public boolean isApplicable(WorkQueue.Task<?> task) {
+    Matcher taskUser = USER_EXTRACT_PATTERN.matcher(task.toString());
+    return taskUser.find() && user.equals(taskUser.group(1)) && super.isApplicable(task);
+  }
+
+  public static Optional<TaskQuota> build(String config) {
+    Matcher matcher = CONFIG_PATTERN.matcher(config);
+    if (matcher.matches()) {
+      return Optional.of(
+          new TaskQuotaForTaskForQueueForUser(
+              matcher.group(4),
+              matcher.group(3),
+              matcher.group(2),
+              Integer.parseInt(matcher.group(1))));
+    } else {
+      log.error("Invalid configuration entry [{}]", config);
+      return Optional.empty();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaKeys.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaKeys.java
new file mode 100644
index 0000000..e8d1c0c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaKeys.java
@@ -0,0 +1,18 @@
+package com.googlesource.gerrit.plugins.quota;
+
+import java.util.Optional;
+import java.util.function.Function;
+
+public enum TaskQuotaKeys {
+  MAX_START_FOR_TASK_FOR_QUEUE("maxStartForTaskForQueue", TaskQuotaForTaskForQueue::build),
+  MAX_START_FOR_TASK_FOR_USER_FOR_QUEUE(
+      "maxStartForTaskForUserForQueue", TaskQuotaForTaskForQueueForUser::build);
+
+  public final String key;
+  public final Function<String, Optional<TaskQuota>> processor;
+
+  TaskQuotaKeys(String key, Function<String, Optional<TaskQuota>> processor) {
+    this.key = key;
+    this.processor = processor;
+  }
+}
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 7d76554..31268a2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
@@ -30,9 +30,8 @@
 public class TaskQuotas implements WorkQueue.TaskParker {
   private static final Logger log = LoggerFactory.getLogger(TaskQuotas.class);
   private final QuotaFinder quotaFinder;
-  private final List<TaskQuotaForTaskForQueue> quotas = new ArrayList<>();
-  private final Map<Integer, List<TaskQuotaForTaskForQueue>> permitsByTask =
-      new ConcurrentHashMap<>();
+  private final List<TaskQuota> quotas = new ArrayList<>();
+  private final Map<Integer, List<TaskQuota>> permitsByTask = new ConcurrentHashMap<>();
 
   @Inject
   public TaskQuotas(QuotaFinder quotaFinder) {
@@ -41,13 +40,13 @@
   }
 
   private void initQuotas() {
-    quotas.addAll(quotaFinder.getGlobalNamespacedQuota().getMaxStartForTaskForQueue());
+    quotas.addAll(quotaFinder.getGlobalNamespacedQuota().getAllQuotas());
   }
 
   @Override
   public boolean isReadyToStart(WorkQueue.Task<?> task) {
-    List<TaskQuotaForTaskForQueue> acquiredQuotas = new ArrayList<>();
-    for (TaskQuotaForTaskForQueue quota : quotas) {
+    List<TaskQuota> acquiredQuotas = new ArrayList<>();
+    for (TaskQuota quota : quotas) {
       if (quota.isApplicable(task)) {
         if (!quota.tryAcquire()) {
           log.debug("Task [{}] will be parked due task quota rules", task);
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index fa0470b..3deb1e5 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -260,6 +260,14 @@
 
 Queue names can be found at `GET /config/server/tasks/ HTTP/1.0`
 
+Additionally, to scope the user use `maxStartForTaskForUserForQueue`
+
+```
+  [quota "*"]
+    maxStartForTaskForUserForQueue = 20 uploadpack userA SSH-Interactive-Worker
+    maxStartForTaskForUserForQueue = 10 uploadpack userB SSH-Batch-Worker
+```
+
 Currently supported tasks:
 
 * `uploadpack`: Maps directly to git-upload-pack operations (used during Git