Initial version for Gerrit 2.14

This plugin allows to enforce rate limits in Gerrit. Rate limits define
the maximum request rate for users in a given group.

This plugin is a fork of rate limits feature of the quota plugin
introduced in [1]. One of the reasons for forking/extracting the rate
limits out of the quota was to be able to use rate limits without the
overhead of quota which is calculating size on disk even if no quota are
set.

Another reason for forking was the way the rate limits were working. One
major difference between this implementation and the original one is the
way the limiter is implemented. Original implementation is using Guava's
RateLimiter and this one is not and implemented its own simplified
RateLimiter which only support hourly rate limits without bursts.

The reason for that simplification is Guava rate limiter unit is seconds
and this caused confusion for blocked users when limit is set using
another unit than seconds. For example, if 60 fetches per hour are
allowed, the user will in fact be allowed 1 fetch per 60 seconds and if
he fetched twice in the same minute, he would have received an error
message saying that he did more that 60 fetches in the last hour.

[1]https://gerrit-review.googlesource.com/c/plugins/quota/+/106976

Change-Id: I00f4c3af55ae540d644e2ca021b5d9758c98b829
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9a6a3fa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/.classpath
+/.project
+/.settings/
+/.primary_build_tool
+/bazel-*
+/eclipse-out/
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..102ba27
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,39 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+load(
+    "//tools/bzl:plugin.bzl",
+    "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
+    "gerrit_plugin",
+)
+
+gerrit_plugin(
+    name = "rate-limiter",
+    srcs = glob(["src/main/java/**/*.java"]),
+    manifest_entries = [
+        "Gerrit-PluginName: rate-limiter",
+        "Gerrit-Module: com.googlesource.gerrit.plugins.ratelimiter.Module",
+        "Gerrit-SshModule: com.googlesource.gerrit.plugins.ratelimiter.SshModule",
+    ],
+    resources = glob(["src/main/resources/**/*"]),
+)
+
+java_library(
+    name = "rate-limiter__plugin_test_deps",
+    testonly = 1,
+    visibility = ["//visibility:public"],
+    exports = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
+        ":rate-limiter__plugin",
+        "@mockito//jar",
+    ],
+)
+
+junit_tests(
+    name = "rate-limiter_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    tags = [
+        "rate-limiter",
+    ],
+    deps = [
+        ":rate-limiter__plugin_test_deps",
+    ],
+)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..11069ed
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+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.
diff --git a/WORKSPACE b/WORKSPACE
new file mode 100644
index 0000000..507f323
--- /dev/null
+++ b/WORKSPACE
@@ -0,0 +1,30 @@
+workspace(name = "rate_limiter")
+
+load("//:bazlets.bzl", "load_bazlets")
+
+load_bazlets(
+    commit = "0cdf281f110834b71ae134afe0a7e3fe346f0078",
+    #local_path = "/home/<user>/projects/bazlets",
+)
+
+#Snapshot Plugin API
+#load(
+#    "@com_googlesource_gerrit_bazlets//:gerrit_api_maven_local.bzl",
+#    "gerrit_api_maven_local",
+#)
+
+# Load snapshot Plugin API
+#gerrit_api_maven_local()
+
+# Release Plugin API
+load(
+    "@com_googlesource_gerrit_bazlets//:gerrit_api.bzl",
+    "gerrit_api",
+)
+
+# Load release Plugin API
+gerrit_api()
+
+load("//:external_plugin_deps.bzl", "external_plugin_deps")
+
+external_plugin_deps()
diff --git a/bazlets.bzl b/bazlets.bzl
new file mode 100644
index 0000000..f089af4
--- /dev/null
+++ b/bazlets.bzl
@@ -0,0 +1,18 @@
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
+
+NAME = "com_googlesource_gerrit_bazlets"
+
+def load_bazlets(
+        commit,
+        local_path = None):
+    if not local_path:
+        git_repository(
+            name = NAME,
+            remote = "https://gerrit.googlesource.com/bazlets",
+            commit = commit,
+        )
+    else:
+        native.local_repository(
+            name = NAME,
+            path = local_path,
+        )
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl
new file mode 100644
index 0000000..703628f
--- /dev/null
+++ b/external_plugin_deps.bzl
@@ -0,0 +1,24 @@
+load("//tools/bzl:maven_jar.bzl", "maven_jar")
+
+def external_plugin_deps():
+    maven_jar(
+        name = "mockito",
+        artifact = "org.mockito:mockito-core:2.23.0",
+        sha1 = "497ddb32fd5d01f9dbe99a2ec790aeb931dff1b1",
+        deps = [
+            "@byte-buddy//jar",
+            "@objenesis//jar",
+        ],
+    )
+
+    maven_jar(
+        name = "byte-buddy",
+        artifact = "net.bytebuddy:byte-buddy:1.7.4",
+        sha1 = "d0e77888485e1683057f8399f916eda6049c4acf",
+    )
+
+    maven_jar(
+        name = "objenesis",
+        artifact = "org.objenesis:objenesis:2.6",
+        sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
+    )
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Configuration.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Configuration.java
new file mode 100644
index 0000000..a0a0dcc
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Configuration.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.common.collect.ArrayTable;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Table;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class Configuration {
+
+  private static final Logger log = LoggerFactory.getLogger(Configuration.class);
+
+  static final String RATE_LIMIT_TOKEN = "${rateLimit}";
+  private static final String GROUP_SECTION = "group";
+  private static final String DEFAULT_UPLOADPACK_LIMIT_EXCEEDED_MSG =
+      "Exceeded rate limit of " + RATE_LIMIT_TOKEN + " fetch requests/hour";
+
+  private Table<RateLimitType, AccountGroup.UUID, RateLimit> rateLimits;
+  private final String rateLimitExceededMsg;
+
+  @Inject
+  Configuration(
+      PluginConfigFactory pluginConfigFactory,
+      @PluginName String pluginName,
+      GroupsCollection groupsCollection) {
+    Config config = pluginConfigFactory.getGlobalPluginConfig(pluginName);
+    parseAllGroupsRateLimits(config, groupsCollection);
+    rateLimitExceededMsg = parseLimitExceededMsg(config);
+  }
+
+  private void parseAllGroupsRateLimits(Config config, GroupsCollection groupsCollection) {
+    Map<String, AccountGroup.UUID> groups = getResolvedGroups(config, groupsCollection);
+    if (groups.size() == 0) {
+      return;
+    }
+    rateLimits = ArrayTable.create(Arrays.asList(RateLimitType.values()), groups.values());
+    for (Entry<String, AccountGroup.UUID> group : groups.entrySet()) {
+      parseGroupRateLimits(config, group.getKey(), group.getValue());
+    }
+  }
+
+  private Map<String, AccountGroup.UUID> getResolvedGroups(
+      Config config, GroupsCollection groupsCollection) {
+    LinkedHashMap<String, AccountGroup.UUID> groups = new LinkedHashMap<>();
+    for (String groupName : config.getSubsections(GROUP_SECTION)) {
+      GroupDescription.Basic groupDesc = groupsCollection.parseId(groupName);
+
+      // Group either is mis-configured, never existed, or was deleted/removed since.
+      if (groupDesc == null) {
+        log.warn(String.format("Invalid configuration, group not found: %s", groupName));
+      } else {
+        groups.put(groupName, groupDesc.getGroupUUID());
+      }
+    }
+    return groups;
+  }
+
+  private void parseGroupRateLimits(Config config, String groupName, AccountGroup.UUID groupUUID)
+      throws ProvisionException {
+    for (String typeName : config.getNames(GROUP_SECTION, groupName, true)) {
+      RateLimitType rateLimitType = RateLimitType.from(typeName);
+      if (rateLimitType != null) {
+        rateLimits.put(rateLimitType, groupUUID, parseRateLimit(config, groupName, rateLimitType));
+      } else {
+        throw new ProvisionException(
+            String.format("Invalid configuration, unsupported rate limit type: %s", typeName));
+      }
+    }
+  }
+
+  private static RateLimit parseRateLimit(Config c, String groupName, RateLimitType rateLimitType) {
+    String value = c.getString(GROUP_SECTION, groupName, rateLimitType.toString());
+    try {
+      return new RateLimit(rateLimitType, Integer.parseInt(value));
+    } catch (NumberFormatException e) {
+      throw new ProvisionException(
+          String.format(
+              "Invalid configuration, 'rate limit value '%s' for '%s.%s.%s' is not a valid number",
+              value, GROUP_SECTION, groupName, rateLimitType.toString()));
+    }
+  }
+
+  private static String parseLimitExceededMsg(Config config) {
+    String msg = config.getString("configuration", null, "uploadpackLimitExceededMsg");
+    return (msg != null) ? msg : DEFAULT_UPLOADPACK_LIMIT_EXCEEDED_MSG;
+  }
+
+  String getRateLimitExceededMsg() {
+    return rateLimitExceededMsg;
+  }
+
+  /**
+   * @param rateLimitType type of rate limit
+   * @return map of rate limits per group uuid
+   */
+  Map<AccountGroup.UUID, RateLimit> getRatelimits(RateLimitType rateLimitType) {
+    return rateLimits != null ? rateLimits.row(rateLimitType) : ImmutableMap.of();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/HourlyRateLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/HourlyRateLimiter.java
new file mode 100644
index 0000000..4f9e44c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/HourlyRateLimiter.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class HourlyRateLimiter implements RateLimiter {
+  private final Semaphore semaphore;
+  private final int maxPermits;
+  private final AtomicInteger usedPermits;
+  private final ScheduledFuture<?> replenishTask;
+
+  interface Factory {
+    HourlyRateLimiter create(int permits);
+  }
+
+  @Inject
+  HourlyRateLimiter(@RateLimitExecutor ScheduledExecutorService executor, @Assisted int permits) {
+    this.semaphore = new Semaphore(permits);
+    this.maxPermits = permits;
+    this.usedPermits = new AtomicInteger();
+    replenishTask = executor.scheduleAtFixedRate(this::replenishPermits, 1, 1, TimeUnit.HOURS);
+  }
+
+  @Override
+  public int permitsPerHour() {
+    return maxPermits;
+  }
+
+  @Override
+  public synchronized boolean acquirePermit() {
+    boolean permit = semaphore.tryAcquire();
+    if (permit) {
+      usedPermits.getAndIncrement();
+    }
+    return permit;
+  }
+
+  @Override
+  public int availablePermits() {
+    return semaphore.availablePermits();
+  }
+
+  @Override
+  public int usedPermits() {
+    return usedPermits.get();
+  }
+
+  @Override
+  public long remainingTime(TimeUnit timeUnit) {
+    return replenishTask.getDelay(timeUnit);
+  }
+
+  @Override
+  public synchronized void replenishPermits() {
+    semaphore.release(usedPermits());
+    usedPermits.set(0);
+  }
+
+  @Override
+  public void close() {
+    replenishTask.cancel(true);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ListCommand.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ListCommand.java
new file mode 100644
index 0000000..e7419a0
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ListCommand.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static com.googlesource.gerrit.plugins.ratelimiter.Module.UPLOAD_PACK_PER_HOUR;
+
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.sshd.AdminHighPriorityCommand;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+@AdminHighPriorityCommand
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+    name = "list",
+    description = "Display rate limits statistics",
+    runsAt = MASTER_OR_SLAVE)
+final class ListCommand extends SshCommand {
+  private static final String FORMAT = "%-26s %-17s %-19s %s";
+  private static final String DASHED_LINE =
+      "------------------------------------------------------------------------------";
+
+  private final LoadingCache<String, RateLimiter> uploadPackPerHour;
+  private final UserResolver userResolver;
+
+  @Inject
+  ListCommand(
+      @Named(UPLOAD_PACK_PER_HOUR) LoadingCache<String, RateLimiter> uploadPackPerHour,
+      UserResolver userResolver) {
+    this.uploadPackPerHour = uploadPackPerHour;
+    this.userResolver = userResolver;
+  }
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    try {
+      stdout.println(DASHED_LINE);
+      stdout.println("* " + UPLOAD_PACK_PER_HOUR + " *");
+      stdout.println(DASHED_LINE);
+      stdout.println(
+          String.format(
+              FORMAT,
+              "Account Id/IP (username)",
+              "Permits Per Hour",
+              "Available Permits",
+              "Replenish in"));
+      stdout.println(DASHED_LINE);
+      uploadPackPerHour
+          .asMap()
+          .entrySet()
+          .stream()
+          .sorted(Map.Entry.comparingByValue())
+          .forEach(this::printEntry);
+      stdout.println(DASHED_LINE);
+    } catch (Exception e) {
+      throw die(e);
+    }
+  }
+
+  private void printEntry(Entry<String, RateLimiter> entry) {
+    stdout.println(
+        String.format(
+            FORMAT,
+            getDisplayValue(entry.getKey()),
+            permits(entry.getValue().permitsPerHour()),
+            permits(entry.getValue().availablePermits()),
+            Duration.ofSeconds(entry.getValue().remainingTime(TimeUnit.SECONDS))));
+  }
+
+  private String permits(int value) {
+    return value == Integer.MAX_VALUE ? "unlimited" : Integer.toString(value);
+  }
+
+  private String getDisplayValue(String key) {
+    Optional<String> currentUser = userResolver.getUserName(key);
+    return currentUser.map(name -> key + " (" + name + ")").orElse(key);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Module.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Module.java
new file mode 100644
index 0000000..191a86a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/Module.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.RemovalListener;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.git.validators.UploadValidationListener;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import com.google.inject.internal.UniqueAnnotations;
+import com.google.inject.name.Named;
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+class Module extends AbstractModule {
+  static final String UPLOAD_PACK_PER_HOUR = "upload_pack_per_hour";
+
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), UploadValidationListener.class).to(RateLimitUploadPack.class);
+    bind(Configuration.class).asEagerSingleton();
+    bind(ScheduledExecutorService.class)
+        .annotatedWith(RateLimitExecutor.class)
+        .toProvider(RateLimitExecutorProvider.class);
+    bind(LifecycleListener.class)
+        .annotatedWith(UniqueAnnotations.create())
+        .to(RateLimitExecutorProvider.class);
+    bind(LifecycleListener.class)
+        .annotatedWith(UniqueAnnotations.create())
+        .to(RateLimiterStatsLog.class);
+    install(new FactoryModuleBuilder().build(HourlyRateLimiter.Factory.class));
+    install(new FactoryModuleBuilder().build(WarningHourlyRateLimiter.Factory.class));
+    install(new FactoryModuleBuilder().build(WarningHourlyUnlimitedRateLimiter.Factory.class));
+  }
+
+  @Provides
+  @Named(UPLOAD_PACK_PER_HOUR)
+  @Singleton
+  LoadingCache<String, RateLimiter> getUploadPackPerHourCache(Provider<RateLimiterLoader> loader) {
+    return CacheBuilder.newBuilder()
+        .expireAfterAccess(1, TimeUnit.HOURS)
+        .removalListener(
+            (RemovalListener<String, RateLimiter>)
+                removalNotification -> removalNotification.getValue().close())
+        .build(loader.get());
+  }
+
+  private static class RateLimiterLoader extends CacheLoader<String, RateLimiter> {
+    private final RateLimitFinder finder;
+    private final HourlyRateLimiter.Factory hourlyRateLimiterFactory;
+    private final WarningHourlyRateLimiter.Factory warningHourlyRateLimiterFactory;
+    private final WarningHourlyUnlimitedRateLimiter.Factory
+        warningHourlyUnlimitedRateLimiterFactory;
+
+    @Inject
+    RateLimiterLoader(
+        RateLimitFinder finder,
+        HourlyRateLimiter.Factory hourlyRateLimiterFactory,
+        WarningHourlyRateLimiter.Factory warningHourlyRateLimiterFactory,
+        WarningHourlyUnlimitedRateLimiter.Factory warningUnlimitedRateLimiterFactory) {
+      this.finder = finder;
+      this.hourlyRateLimiterFactory = hourlyRateLimiterFactory;
+      this.warningHourlyRateLimiterFactory = warningHourlyRateLimiterFactory;
+      this.warningHourlyUnlimitedRateLimiterFactory = warningUnlimitedRateLimiterFactory;
+    }
+
+    @Override
+    public RateLimiter load(String key) {
+      Optional<RateLimit> limit = finder.find(RateLimitType.UPLOAD_PACK_PER_HOUR, key);
+      Optional<RateLimit> warn = finder.find(RateLimitType.UPLOAD_PACK_PER_HOUR_WARN, key);
+      if (!limit.isPresent() && !warn.isPresent()) {
+        return UnlimitedRateLimiter.INSTANCE;
+      }
+
+      // In the case that there is a warning but no limit
+      Integer myLimit = Integer.MAX_VALUE;
+      if (limit.isPresent()) {
+        myLimit = limit.get().getRatePerHour();
+      }
+
+      RateLimiter rateLimiter = hourlyRateLimiterFactory.create(myLimit);
+
+      if (warn.isPresent()) {
+        if (limit.isPresent()) {
+          return warningHourlyRateLimiterFactory.create(
+              rateLimiter, key, warn.get().getRatePerHour());
+        }
+        return warningHourlyUnlimitedRateLimiterFactory.create(
+            rateLimiter, key, warn.get().getRatePerHour());
+      }
+      return rateLimiter;
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimit.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimit.java
new file mode 100644
index 0000000..654dd52
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimit.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+class RateLimit {
+  private final RateLimitType rateLimitType;
+  private final int ratePerHour;
+
+  RateLimit(RateLimitType rateLimitType, int ratePerHour) {
+    this.rateLimitType = rateLimitType;
+    this.ratePerHour = ratePerHour;
+  }
+
+  RateLimitType getType() {
+    return rateLimitType;
+  }
+
+  int getRatePerHour() {
+    return ratePerHour;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitException.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitException.java
new file mode 100644
index 0000000..8e70508
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitException.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.gerrit.server.validators.ValidationException;
+
+class RateLimitException extends ValidationException {
+  private static final long serialVersionUID = 1L;
+
+  RateLimitException(String msg) {
+    super(msg);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitExecutor.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitExecutor.java
new file mode 100644
index 0000000..b7d25f1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+@interface RateLimitExecutor {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitExecutorProvider.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitExecutorProvider.java
new file mode 100644
index 0000000..d6c60f6
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitExecutorProvider.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+@Singleton
+class RateLimitExecutorProvider implements Provider<ScheduledExecutorService>, LifecycleListener {
+  private ScheduledExecutorService executor;
+
+  RateLimitExecutorProvider() {
+    executor =
+        Executors.newSingleThreadScheduledExecutor(
+            new ThreadFactoryBuilder().setNameFormat("Rate-limit-replenisher-%d").build());
+  }
+
+  @Override
+  public void start() {
+    // do nothing
+  }
+
+  @Override
+  public void stop() {
+    executor.shutdownNow();
+    executor = null;
+  }
+
+  @Override
+  public ScheduledExecutorService get() {
+    return executor;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitFinder.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitFinder.java
new file mode 100644
index 0000000..173d1fc
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitFinder.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+
+@Singleton
+class RateLimitFinder {
+
+  private final Configuration configuration;
+  private final UserResolver userResolver;
+  private final AccountGroup.UUID anonymousUsersGroupUUID;
+
+  @Inject
+  RateLimitFinder(
+      Configuration configuration,
+      UserResolver userResolver,
+      SystemGroupBackend systemGroupBackend) {
+    this.configuration = configuration;
+    this.userResolver = userResolver;
+    anonymousUsersGroupUUID = systemGroupBackend.get(ANONYMOUS_USERS).getGroupUUID();
+  }
+
+  Optional<RateLimit> find(RateLimitType rateLimitType, String key) {
+    Optional<IdentifiedUser> currentUser = userResolver.getIdentifiedUser(key);
+    return currentUser.isPresent()
+        ? firstMatching(rateLimitType, currentUser.get())
+        : getRateLimit(rateLimitType, anonymousUsersGroupUUID);
+  }
+
+  /**
+   * @param rateLimitType type of rate limit
+   * @param user identified user
+   * @return the rate limit matching the first configured group limit in which the user is a member
+   */
+  private Optional<RateLimit> firstMatching(RateLimitType rateLimitType, IdentifiedUser user) {
+    Map<AccountGroup.UUID, RateLimit> limitsPerGroupUUID =
+        configuration.getRatelimits(rateLimitType);
+    if (!limitsPerGroupUUID.isEmpty()) {
+      GroupMembership memberShip = user.getEffectiveGroups();
+      for (Entry<AccountGroup.UUID, RateLimit> limitPerGroupUUID : limitsPerGroupUUID.entrySet()) {
+        if (memberShip.contains(limitPerGroupUUID.getKey())) {
+          return Optional.ofNullable(limitPerGroupUUID.getValue());
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * @param rateLimitType type of rate limit
+   * @param groupUUID uuid of group to lookup up rate limit for
+   * @return rate limit
+   */
+  private Optional<RateLimit> getRateLimit(
+      RateLimitType rateLimitType, AccountGroup.UUID groupUUID) {
+    Map<AccountGroup.UUID, RateLimit> limits = configuration.getRatelimits(rateLimitType);
+    return limits.isEmpty() ? Optional.empty() : Optional.ofNullable(limits.get(groupUUID));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitType.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitType.java
new file mode 100644
index 0000000..0d6e4e5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitType.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+enum RateLimitType {
+  UPLOAD_PACK_PER_HOUR("uploadpackperhour"),
+  UPLOAD_PACK_PER_HOUR_WARN("uploadpackperhourwarn");
+
+  private final String type;
+
+  RateLimitType(String type) {
+    this.type = type;
+  }
+
+  @Override
+  public String toString() {
+    return type;
+  }
+
+  static RateLimitType from(String value) {
+    for (RateLimitType rateLimitType : RateLimitType.values()) {
+      if (rateLimitType.toString().equalsIgnoreCase(value)) {
+        return rateLimitType;
+      }
+    }
+    return null;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitUploadPack.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitUploadPack.java
new file mode 100644
index 0000000..3af4f5b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitUploadPack.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.googlesource.gerrit.plugins.ratelimiter.Configuration.RATE_LIMIT_TOKEN;
+import static com.googlesource.gerrit.plugins.ratelimiter.Module.UPLOAD_PACK_PER_HOUR;
+
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.validators.UploadValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.text.MessageFormat;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.UploadPack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+class RateLimitUploadPack implements UploadValidationListener {
+  private static final Logger log = LoggerFactory.getLogger(RateLimitUploadPack.class);
+
+  private final Provider<CurrentUser> user;
+  private final LoadingCache<String, RateLimiter> uploadPackPerHour;
+  private final String limitExceededMsgFormat;
+
+  @Inject
+  RateLimitUploadPack(
+      Provider<CurrentUser> user,
+      @Named(UPLOAD_PACK_PER_HOUR) LoadingCache<String, RateLimiter> uploadPackPerHour,
+      Configuration configuration) {
+    this.user = user;
+    this.uploadPackPerHour = uploadPackPerHour;
+    limitExceededMsgFormat =
+        configuration.getRateLimitExceededMsg().replace(RATE_LIMIT_TOKEN, "{0,number,##.##}");
+  }
+
+  @Override
+  public void onBeginNegotiate(
+      Repository repository,
+      Project project,
+      String remoteHost,
+      UploadPack up,
+      Collection<? extends ObjectId> wants,
+      int cntOffered)
+      throws ValidationException {
+    String key;
+    CurrentUser u = user.get();
+    if (u.isIdentifiedUser()) {
+      key = Integer.toString(u.asIdentifiedUser().getAccountId().get());
+    } else {
+      key = remoteHost;
+    }
+
+    try {
+      RateLimiter limiter = uploadPackPerHour.get(key);
+      if (limiter != null && !limiter.acquirePermit()) {
+        throw new RateLimitException(
+            MessageFormat.format(limitExceededMsgFormat, limiter.permitsPerHour()));
+      }
+    } catch (ExecutionException e) {
+      log.warn("Cannot get rate limits for {}: {}", key, e);
+    }
+  }
+
+  @Override
+  public void onPreUpload(
+      Repository repository,
+      Project project,
+      String remoteHost,
+      UploadPack up,
+      Collection<? extends ObjectId> wants,
+      Collection<? extends ObjectId> haves)
+      throws ValidationException {
+    // nothing to do here, only onBeginNegotiate is needed
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiter.java
new file mode 100644
index 0000000..81280ba
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiter.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import java.util.Comparator;
+import java.util.concurrent.TimeUnit;
+
+interface RateLimiter extends Comparable<RateLimiter> {
+  Comparator<RateLimiter> REVERSE_ORDER_COMPARATOR =
+      Comparator.comparing(RateLimiter::availablePermits).reversed();
+
+  @Override
+  public default int compareTo(RateLimiter other) {
+    return REVERSE_ORDER_COMPARATOR.compare(this, other);
+  }
+
+  /** Returns number of permits allowed per hour. */
+  int permitsPerHour();
+
+  /**
+   * Acquire an available permit if any left.
+   *
+   * @return true if permit was acquired, otherwise false.
+   */
+  boolean acquirePermit();
+
+  /** Returns the number of available permits left. */
+  int availablePermits();
+
+  /** Returns the number of permits used in the hour. */
+  int usedPermits();
+
+  /** Returns remaining time before available permits are replenished, in the given time unit. */
+  long remainingTime(TimeUnit timeUnit);
+
+  /** Replenish available permits to the number allowed per hour. */
+  void replenishPermits();
+
+  /** Closes this RateLimiter, relinquishing any underlying resources. */
+  void close();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterStatsLog.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterStatsLog.java
new file mode 100644
index 0000000..56b02c5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimiterStatsLog.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.server.util.PluginLogFile;
+import com.google.gerrit.server.util.SystemLog;
+import com.google.inject.Inject;
+import org.apache.log4j.PatternLayout;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class RateLimiterStatsLog extends PluginLogFile {
+
+  @Inject
+  RateLimiterStatsLog(SystemLog systemLog, ServerInformation serverInfo) {
+    super(systemLog, serverInfo, getLogger().getName(), new PatternLayout("[%d] %m%n"));
+  }
+
+  static Logger getLogger() {
+    return LoggerFactory.getLogger(RateLimiterStatsLog.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ReplenishCommand.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ReplenishCommand.java
new file mode 100644
index 0000000..f86784f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/ReplenishCommand.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static com.googlesource.gerrit.plugins.ratelimiter.Module.UPLOAD_PACK_PER_HOUR;
+
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.CapabilityScope;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+@RequiresCapability(value = GlobalCapability.ADMINISTRATE_SERVER, scope = CapabilityScope.CORE)
+@CommandMetaData(
+    runsAt = MASTER_OR_SLAVE,
+    name = "replenish",
+    description = "Replenishes uploadpack permits for a given user or remote host.")
+final class ReplenishCommand extends SshCommand {
+
+  @Option(name = "--all", usage = "replenish all permits ")
+  private boolean all;
+
+  @Option(
+      name = "--user",
+      metaVar = "USER",
+      usage = "full name, email-address, ssh username or account id")
+  private List<Account.Id> accountIds = new ArrayList<>();
+
+  @Option(name = "--remotehost", usage = "IP of the remotehost", metaVar = "IP")
+  private List<String> remoteHosts = new ArrayList<>();
+
+  private final LoadingCache<String, RateLimiter> uploadPackPerHour;
+
+  @Inject
+  ReplenishCommand(
+      @Named(UPLOAD_PACK_PER_HOUR) LoadingCache<String, RateLimiter> uploadPackPerHour) {
+    this.uploadPackPerHour = uploadPackPerHour;
+  }
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    if (all && (!accountIds.isEmpty() || !remoteHosts.isEmpty())) {
+      throw die("cannot use --all with --user or --remotehost");
+    }
+    if (all) {
+      for (RateLimiter rateLimiter : uploadPackPerHour.asMap().values()) {
+        rateLimiter.replenishPermits();
+      }
+      return;
+    }
+    for (Account.Id accountId : accountIds) {
+      replenishIfPresent(Integer.toString(accountId.get()));
+    }
+    for (String remoteHost : remoteHosts) {
+      replenishIfPresent(remoteHost);
+    }
+  }
+
+  private void replenishIfPresent(String key) {
+    RateLimiter limiter = uploadPackPerHour.getIfPresent(key);
+    if (limiter != null) {
+      limiter.replenishPermits();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/SshModule.java
new file mode 100644
index 0000000..85c0035
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/SshModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.gerrit.sshd.PluginCommandModule;
+
+class SshModule extends PluginCommandModule {
+
+  @Override
+  protected void configureCommands() {
+    command(ReplenishCommand.class);
+    command(ListCommand.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/UnlimitedRateLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/UnlimitedRateLimiter.java
new file mode 100644
index 0000000..a3e682e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/UnlimitedRateLimiter.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import java.util.concurrent.TimeUnit;
+
+class UnlimitedRateLimiter implements RateLimiter {
+
+  static final UnlimitedRateLimiter INSTANCE = new UnlimitedRateLimiter();
+
+  private UnlimitedRateLimiter() {}
+
+  @Override
+  public int permitsPerHour() {
+    return Integer.MAX_VALUE;
+  }
+
+  @Override
+  public boolean acquirePermit() {
+    return true;
+  }
+
+  @Override
+  public int availablePermits() {
+    return Integer.MAX_VALUE;
+  }
+
+  @Override
+  public long remainingTime(TimeUnit timeUnit) {
+    return 0;
+  }
+
+  @Override
+  public void replenishPermits() {
+    // do nothing
+  }
+
+  @Override
+  public int usedPermits() {
+    return 0;
+  }
+
+  @Override
+  public void close() {
+    // do nothing
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/UserResolver.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/UserResolver.java
new file mode 100644
index 0000000..852367e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/UserResolver.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.inject.Inject;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+class UserResolver {
+  private static final Pattern DIGITS = Pattern.compile("[0-9]+");
+
+  private final GenericFactory userFactory;
+
+  @Inject
+  UserResolver(IdentifiedUser.GenericFactory userFactory) {
+    this.userFactory = userFactory;
+  }
+
+  Optional<IdentifiedUser> getIdentifiedUser(String key) {
+    return isNumeric(key)
+        ? Optional.ofNullable(userFactory.create(new Account.Id(Integer.parseInt(key))))
+        : Optional.empty();
+  }
+
+  Optional<String> getUserName(String key) {
+    Optional<IdentifiedUser> user = getIdentifiedUser(key);
+    return user.isPresent() ? Optional.ofNullable(user.get().getUserName()) : Optional.empty();
+  }
+
+  private static boolean isNumeric(String key) {
+    return DIGITS.matcher(key).matches();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyRateLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyRateLimiter.java
new file mode 100644
index 0000000..497917e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyRateLimiter.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+
+class WarningHourlyRateLimiter implements RateLimiter {
+  @FunctionalInterface
+  interface Factory {
+    WarningHourlyRateLimiter create(RateLimiter delegate, String key, int warnLimit);
+  }
+
+  private static final Logger rateLimitLog = RateLimiterStatsLog.getLogger();
+  private static final DateTimeFormatter format = DateTimeFormatter.ofPattern("mm 'min' ss 'sec'");
+
+  private final UserResolver userResolver;
+  private final RateLimiter delegate;
+  private final int warnLimit;
+  private final String key;
+
+  private volatile boolean wasLogged;
+  private volatile boolean warningWasLogged = false;
+
+  @Inject
+  WarningHourlyRateLimiter(
+      UserResolver userResolver,
+      @Assisted RateLimiter delegate,
+      @Assisted String key,
+      @Assisted int warnLimit) {
+    this.userResolver = userResolver;
+    this.delegate = delegate;
+    this.warnLimit = warnLimit;
+    this.key = key;
+  }
+
+  @Override
+  public int permitsPerHour() {
+    return delegate.permitsPerHour();
+  }
+
+  @Override
+  public synchronized boolean acquirePermit() {
+    boolean acquirePermit = delegate.acquirePermit();
+    if (usedPermits() == warnLimit) {
+      rateLimitLog.info(
+          "{} reached the warning limit of {} uploadpacks per hour.",
+          userResolver.getUserName(key).orElse(key),
+          warnLimit);
+      warningWasLogged = true;
+    }
+
+    if (!acquirePermit && !wasLogged) {
+      rateLimitLog.info(
+          "{} was blocked due to exceeding the limit of {} uploadpacks per hour."
+              + " {} remaining to permits replenishing.",
+          userResolver.getUserName(key).orElse(key),
+          permitsPerHour(),
+          secondsToMsSs(remainingTime(TimeUnit.SECONDS)));
+      wasLogged = true;
+    }
+    return acquirePermit;
+  }
+
+  @Override
+  public int availablePermits() {
+    return delegate.availablePermits();
+  }
+
+  @Override
+  public long remainingTime(TimeUnit timeUnit) {
+    return delegate.remainingTime(timeUnit);
+  }
+
+  @Override
+  public void replenishPermits() {
+    warningWasLogged = false;
+    delegate.replenishPermits();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @Override
+  public int usedPermits() {
+    return delegate.usedPermits();
+  }
+
+  private String secondsToMsSs(long seconds) {
+    return LocalTime.MIN.plusSeconds(seconds).format(format);
+  }
+
+  @VisibleForTesting
+  public boolean getWarningFlagState() {
+    return warningWasLogged;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyUnlimitedRateLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyUnlimitedRateLimiter.java
new file mode 100644
index 0000000..d2b8b09
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyUnlimitedRateLimiter.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+
+class WarningHourlyUnlimitedRateLimiter implements RateLimiter {
+  @FunctionalInterface
+  interface Factory {
+    WarningHourlyUnlimitedRateLimiter create(RateLimiter delegate, String key, int warnLimit);
+  }
+
+  private static final Logger rateLimitLog = RateLimiterStatsLog.getLogger();
+
+  private final UserResolver userResolver;
+  private final RateLimiter delegate;
+  private final int warnLimit;
+  private final String key;
+  private volatile boolean warningWasLogged = false;
+
+  @Inject
+  WarningHourlyUnlimitedRateLimiter(
+      UserResolver userResolver,
+      @Assisted RateLimiter delegate,
+      @Assisted String key,
+      @Assisted int warnLimit) {
+    this.userResolver = userResolver;
+    this.delegate = delegate;
+    this.warnLimit = warnLimit;
+    this.key = key;
+  }
+
+  @Override
+  public int permitsPerHour() {
+    return Integer.MAX_VALUE;
+  }
+
+  @Override
+  public boolean acquirePermit() {
+    boolean acquirePermit = delegate.acquirePermit();
+
+    if (acquirePermit && (usedPermits() == warnLimit)) {
+      rateLimitLog.info(
+          "{} reached the warning limit of {} uploadpacks per hour.",
+          userResolver.getUserName(key).orElse(key),
+          warnLimit);
+      warningWasLogged = true;
+    }
+    return acquirePermit;
+  }
+
+  @Override
+  public int availablePermits() {
+    return Integer.MAX_VALUE;
+  }
+
+  @Override
+  public long remainingTime(TimeUnit timeUnit) {
+    return delegate.remainingTime(timeUnit);
+  }
+
+  @Override
+  public void replenishPermits() {
+    warningWasLogged = false;
+    delegate.replenishPermits();
+  }
+
+  @Override
+  public int usedPermits() {
+    return delegate.usedPermits();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+
+  @VisibleForTesting
+  public boolean getWarningFlagState() {
+    return warningWasLogged;
+  }
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
new file mode 100644
index 0000000..3313b71
--- /dev/null
+++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,8 @@
+This plugin allows to enforce rate limits in Gerrit.
+
+The @PLUGIN@ plugin supports the following rate limits:
+
+* `uploadpackperhour` requests per hour which are executed when a client runs a fetch command.
+
+Rate limits define the maximum request rate for users in a given group
+for a given request type.
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md
new file mode 100644
index 0000000..d21def4
--- /dev/null
+++ b/src/main/resources/Documentation/build.md
@@ -0,0 +1,81 @@
+# Build
+
+This plugin can be built with Bazel, and two build modes are supported:
+
+* Standalone
+* In Gerrit tree
+
+Standalone build mode is recommended, as this mode doesn't require local Gerrit
+tree to exist.
+
+## Build standalone
+
+To build the plugin, issue the following command:
+
+```
+  bazel build @PLUGIN@
+```
+
+The output is created in
+
+```
+  bazel-genfiles/@PLUGIN@.jar
+```
+
+To package the plugin sources run:
+
+```
+  bazel build lib@PLUGIN@__plugin-src.jar
+```
+
+The output is created in:
+
+```
+  bazel-bin/lib@PLUGIN@__plugin-src.jar
+```
+
+To execute the tests run:
+
+```
+  bazel test //...
+```
+
+This project can be imported into the Eclipse IDE:
+
+```
+  ./tools/eclipse/project.sh
+```
+
+## Build in Gerrit tree
+
+Clone or link this plugin to the plugins directory of Gerrit's
+source tree. From Gerrit source tree issue the command:
+
+```
+  bazel build plugins/@PLUGIN@
+```
+
+The output is created in
+
+```
+  bazel-genfiles/plugins/@PLUGIN@/@PLUGIN@.jar
+```
+
+This project can be imported into the Eclipse IDE:
+Add the plugin name to the `CUSTOM_PLUGINS` in `tools/bzl/plugins.bzl`, and
+execute:
+
+```
+  ./tools/eclipse/project.py
+```
+
+To execute the tests run:
+
+```
+  bazel test plugins/@PLUGIN@:@PLUGIN@_tests
+```
+
+
+[Back to @PLUGIN@ documentation index][index]
+
+[index]: index.html
diff --git a/src/main/resources/Documentation/cmd-list.md b/src/main/resources/Documentation/cmd-list.md
new file mode 100644
index 0000000..d541cfa
--- /dev/null
+++ b/src/main/resources/Documentation/cmd-list.md
@@ -0,0 +1,36 @@
+@PLUGIN@ list
+=================
+
+NAME
+----
+@PLUGIN@ list display rate limit statistics
+
+SYNOPSIS
+--------
+>     ssh -p <port> <host> @PLUGIN@ list
+
+DESCRIPTION
+-----------
+Displays rate limit statistics: account id (or IP if request is anonymous),
+permits per hour, remaining permits and when they will be replenished.
+
+The time before permits are replenished is represented using ISO-8601 seconds
+based representation, such as PT59M30S (59 minutes and 30 seconds).
+
+ACCESS
+------
+Gerrit Administrators only.
+
+EXAMPLES
+--------
+
+>     $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ list
+>     ------------------------------------------------------------------------------
+>     * upload_pack_per_hour *
+>     ------------------------------------------------------------------------------
+>     Account Id (or IP)   Permits Per Hour  Available Permits   Replenish in
+>     ------------------------------------------------------------------------------
+>     1000001              unlimited         unlimited           PT0S
+>     1000002              1000              999                 PT59M30S
+>     127.0.0.1            1000              123                 PT10M26S
+>     ------------------------------------------------------------------------------
diff --git a/src/main/resources/Documentation/cmd-replenish.md b/src/main/resources/Documentation/cmd-replenish.md
new file mode 100644
index 0000000..d9ed445
--- /dev/null
+++ b/src/main/resources/Documentation/cmd-replenish.md
@@ -0,0 +1,49 @@
+@PLUGIN@ replenish
+======================
+
+NAME
+----
+@PLUGIN@ replenish
+
+SYNOPSIS
+--------
+>     ssh -p <port> <host> @PLUGIN@ replenish
+>      [--all]
+>      [--user] <USER>
+>      [--remotehost] <REMOTEHOST>
+
+DESCRIPTION
+-----------
+Replenishes all uploadpackperhour permits for a given remotehost/user, or all.
+
+PARAMETERS
+----------
+
+`remotehost`
+> Remote host to replenish permits for.
+
+`user`
+> User to replenish permits for.
+
+ACCESS
+------
+Gerrit Administrators only.
+
+OPTIONS
+-------
+`--remotehost`
+> Replenish permits for a given remote host.
+
+`--user`
+> Replenish permits for a given user.
+
+EXAMPLES
+--------
+Replenish all permits for a remotehost 127.0.0.1
+>     $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ replenish --remotehost 127.0.0.1
+
+Replenish all permits for a user 'admin'
+>     $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ replenish --user admin
+
+Replenish all permits
+>     $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ replenish --all
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
new file mode 100644
index 0000000..098f1e2
--- /dev/null
+++ b/src/main/resources/Documentation/config.md
@@ -0,0 +1,106 @@
+Configuration
+=============
+
+Rate Limits
+-----------
+
+The defined rate limits are stored in a `rate-limiter.config` file in the
+`{review_site}/etc` directory. Rate limits are defined per user group and
+rate limit type.
+
+Example:
+
+```
+  [group "buildserver"]
+    uploadpackperhour = 10
+    uploadpackperhourwarn = 8
+
+  [group "Registered Users"]
+    uploadpackperhour = 1
+
+  [group "Anonymous Users"]
+    uploadpackperhour = 6
+
+  [group "gerrit-user"]
+    uploadpackperhourwarn = 10
+```
+
+For logged-in users, rate limits are associated to their accountId. For
+anonymous users, rate limits are associated to their remote host address.
+If multiple anonymous users are accessing Gerrit via the same host (e.g.,
+a proxy), then they share a common rate limit.
+
+If a user is a member of multiple groups mentioned in `rate-limiter.config`,
+the limit that applies is defined first in the `rate-limiter.config` file.
+This resolves ambiguity in case the user is a member of multiple groups
+used in the configuration.
+
+Use group "Anonymous Users" to define the rate limit for anonymous users.
+Use group "Registered Users" to define the default rate limit for all logged-in
+users.
+
+A second, "soft" limit can be defined for every rate limit, to warn
+administrators about the users that can be affected by an upcoming lower limit.
+In this case, the "soft" limit has the same name as the original limit, but
+ends with the "warn" suffix. For example, for the `uploadpackperhour` limit,
+its "soft" counterpart will be called `uploadpackperhourwarn`:
+
+```
+  [group "Registered Users"]
+    uploadpackperhour = 100
+    uploadpackperhourwarn = 50
+```
+
+When a registered user reaches the "soft" limit (50 uploads for the example),
+a warn message is logged in the `RateLimiterStatsLog`, located in the
+`<gerrit_site>/logs` folder:
+
+```
+  [2018-06-04 05:40:36,006] user reached the limit of 50
+```
+
+The upload limitation will be enforced, i.e., the operation will be blocked,
+only when the user reaches 100 uploads.
+
+If the warn limit is present in the configuration but no hard limit,
+then no limit will be enforced but a log entry will be written when
+the user reaches the warning limit.
+
+Format of the rate limit entries in `rate-limiter.config`:
+
+```
+  [group "<groupName>"]
+    <rateLimitType> = <rateLimit>
+```
+
+<a id="rateLimitType>">
+`group.<groupName>.rateLimitType`
+: identifies which request type is limited by this configuration.
+The following rate limit types are supported:
+* `uploadpackperhour`: rate limit for uploadpack (fetch) requests.
+
+The group can be defined by its name or UUID.
+
+<a id="uploadpackperhour">
+`group.<groupName>.uploadpackperhour`
+: configures the rate limit of fetch requests for the given group.
+
+If a rate limit configuration value is invalid, a default rate limit of
+1000 requests per hour is assumed.
+
+Example:
+
+Configures a rate limit of maximum 30 fetch requests per hour for the
+group of registered users.
+
+```
+  [group "Registered Users"]
+    uploadpackperhour = 30
+```
+
+The rate limit exceeded message can be configured by setting the
+`configuration.uploadpackLimitExceededMsg` parameter in the
+`rate-limiter.config` file. The `${rateLimit}` token is supported in the
+message and will be replaced by the effective rate limit per hour.
+
+Defaults to `Exceeded rate limit of ${rateLimit} fetch requests/hour`.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/ConfigurationTest.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/ConfigurationTest.java
new file mode 100644
index 0000000..9bb1c94
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/ConfigurationTest.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.inject.ProvisionException;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ConfigurationTest {
+  private static final String PLUGIN_NAME = "rate-limiter";
+
+  @Rule public ExpectedException exception = ExpectedException.none();
+  @Mock private PluginConfigFactory pluginConfigFactoryMock;
+  @Mock private GroupsCollection groupsCollectionMock;
+  @Mock private GroupDescription.Basic administratorsGroupDescMock;
+  @Mock private GroupDescription.Basic someGroupDescMock;
+  private Config globalPluginConfig;
+  private final int validRate = 123;
+  private final String invalidType = "dummyType";
+  private final String groupTagName = "group";
+
+  @Before
+  public void setUp() {
+    globalPluginConfig = new Config();
+    when(pluginConfigFactoryMock.getGlobalPluginConfig(PLUGIN_NAME)).thenReturn(globalPluginConfig);
+
+    when(administratorsGroupDescMock.getName()).thenReturn("Administrators");
+    when(administratorsGroupDescMock.getGroupUUID())
+        .thenReturn(new AccountGroup.UUID("admin_uuid"));
+    when(groupsCollectionMock.parseId(administratorsGroupDescMock.getName()))
+        .thenReturn(administratorsGroupDescMock);
+
+    when(someGroupDescMock.getName()).thenReturn("someGroup");
+    when(someGroupDescMock.getGroupUUID()).thenReturn(new AccountGroup.UUID("some_uuid"));
+    when(groupsCollectionMock.parseId(someGroupDescMock.getName())).thenReturn(someGroupDescMock);
+  }
+
+  private Configuration getConfiguration() {
+    return new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, groupsCollectionMock);
+  }
+
+  @Test
+  public void testEmptyConfig() {
+    assertThat(getConfiguration().getRatelimits(RateLimitType.UPLOAD_PACK_PER_HOUR)).isEmpty();
+  }
+
+  @Test
+  public void testUploadPackPerHourRateLimit() {
+    globalPluginConfig.setInt(
+        groupTagName,
+        someGroupDescMock.getName(),
+        RateLimitType.UPLOAD_PACK_PER_HOUR.toString(),
+        validRate);
+
+    Map<AccountGroup.UUID, RateLimit> rateLimit =
+        getConfiguration().getRatelimits(RateLimitType.UPLOAD_PACK_PER_HOUR);
+    assertThat(rateLimit).hasSize(1);
+    assertThat(rateLimit.get(someGroupDescMock.getGroupUUID()).getRatePerHour())
+        .isEqualTo(validRate);
+  }
+
+  @Test
+  public void testInvalidRateLimitType() {
+    globalPluginConfig.setInt(
+        groupTagName, someGroupDescMock.getName(), "invalidTypePerHour", validRate);
+
+    exception.expect(ProvisionException.class);
+    exception.expectMessage(
+        "Invalid configuration, unsupported rate limit type: invalidTypePerHour");
+    getConfiguration();
+  }
+
+  @Test
+  public void testInvalidRateLimitValue() {
+    globalPluginConfig.setString(
+        groupTagName,
+        someGroupDescMock.getName(),
+        RateLimitType.UPLOAD_PACK_PER_HOUR.toString(),
+        invalidType);
+
+    exception.expect(ProvisionException.class);
+    exception.expectMessage(
+        "Invalid configuration, 'rate limit value '"
+            + invalidType
+            + "' for 'group.someGroup.uploadpackperhour' is not a valid number");
+    getConfiguration();
+  }
+
+  @Test
+  public void testInvalidGroup() {
+
+    // Set a good group and a bad and ensure the good is still parsed
+    globalPluginConfig.setInt(
+        groupTagName,
+        someGroupDescMock.getName(),
+        RateLimitType.UPLOAD_PACK_PER_HOUR.toString(),
+        validRate);
+
+    globalPluginConfig.setString(
+        groupTagName,
+        "nonexistingGroup",
+        RateLimitType.UPLOAD_PACK_PER_HOUR.toString(),
+        "badGroup");
+
+    Map<AccountGroup.UUID, RateLimit> rateLimit =
+        getConfiguration().getRatelimits(RateLimitType.UPLOAD_PACK_PER_HOUR);
+    assertThat(rateLimit).hasSize(1);
+    assertThat(rateLimit.get(someGroupDescMock.getGroupUUID()).getRatePerHour())
+        .isEqualTo(validRate);
+  }
+
+  @Test
+  public void testNoUploadPackPerHourRateLimitForAGroup() throws ConfigInvalidException {
+    globalPluginConfig.fromText("[group \"Administrators\"]");
+
+    Map<AccountGroup.UUID, RateLimit> rateLimit =
+        getConfiguration().getRatelimits(RateLimitType.UPLOAD_PACK_PER_HOUR);
+    assertThat(rateLimit).hasSize(1);
+    assertThat(rateLimit.get(administratorsGroupDescMock.getGroupUUID())).isNull();
+  }
+
+  @Test
+  public void testDefaultRateLimitExceededMsg() {
+    assertThat(getConfiguration().getRateLimitExceededMsg())
+        .isEqualTo("Exceeded rate limit of ${rateLimit} fetch requests/hour");
+  }
+
+  @Test
+  public void testRateLimitExceededMsg() {
+    String msg = "Some error message.";
+    globalPluginConfig.setString("configuration", null, "uploadpackLimitExceededMsg", msg);
+    assertThat(getConfiguration().getRateLimitExceededMsg()).isEqualTo(msg);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/HourlyRateLimiterTest.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/HourlyRateLimiterTest.java
new file mode 100644
index 0000000..7040682
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/HourlyRateLimiterTest.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+public class HourlyRateLimiterTest {
+
+  private static final int RATE = 1000;
+
+  private HourlyRateLimiter limiter;
+  private ScheduledExecutorService scheduledExecutorMock;
+
+  @Before
+  public void setUp() {
+    scheduledExecutorMock = mock(ScheduledExecutorService.class);
+    limiter = new HourlyRateLimiter(scheduledExecutorMock, RATE);
+  }
+
+  @Test
+  public void testGetRatePerHour() {
+    assertThat(limiter.permitsPerHour()).isEqualTo(RATE);
+  }
+
+  @Test
+  public void testAcquire() {
+    assertThat(limiter.availablePermits()).isEqualTo(RATE);
+
+    for (int i = 1; i <= 1000; i++) {
+      assertThat(limiter.acquirePermit()).isTrue();
+      assertThat(limiter.availablePermits()).isEqualTo(RATE - i);
+    }
+    assertThat(limiter.acquirePermit()).isFalse();
+    assertThat(limiter.availablePermits()).isEqualTo(0);
+  }
+
+  @Test
+  public void testReplenishPermits() {
+    testAcquire();
+    limiter.replenishPermits();
+    testAcquire();
+  }
+
+  @Test
+  public void testReplenishPermitsIsScheduled() {
+    verify(scheduledExecutorMock).scheduleAtFixedRate(any(), eq(1L), eq(1L), eq(TimeUnit.HOURS));
+  }
+
+  @Test
+  public void testReplenishPermitsScheduledRunnableIsWorking() {
+    ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
+    verify(scheduledExecutorMock)
+        .scheduleAtFixedRate(runnableCaptor.capture(), eq(1L), eq(1L), eq(TimeUnit.HOURS));
+
+    // Use all permits
+    testAcquire();
+
+    // force execution of the runnable that replenish the permits
+    runnableCaptor.getValue().run();
+
+    assertThat(limiter.availablePermits()).isEqualTo(RATE);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitTest.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitTest.java
new file mode 100644
index 0000000..afdd27e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitTest.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class RateLimitTest {
+
+  @Test
+  public void testRateLimit() {
+    RateLimit rateLimit = new RateLimit(RateLimitType.UPLOAD_PACK_PER_HOUR, 123);
+
+    assertThat(rateLimit.getType()).isEqualTo(RateLimitType.UPLOAD_PACK_PER_HOUR);
+    assertThat(rateLimit.getRatePerHour()).isEqualTo(123);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitTypeTest.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitTypeTest.java
new file mode 100644
index 0000000..d86e22f
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitTypeTest.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class RateLimitTypeTest {
+
+  @Test
+  public void testToString() {
+    assertThat(RateLimitType.UPLOAD_PACK_PER_HOUR.toString()).isEqualTo("uploadpackperhour");
+  }
+
+  @Test
+  public void testFromString() {
+    assertThat(RateLimitType.from("uploadpackperhour"))
+        .isEqualTo(RateLimitType.UPLOAD_PACK_PER_HOUR);
+    assertThat(RateLimitType.from("non-existing-type")).isNull();
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitUploadPackIT.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitUploadPackIT.java
new file mode 100644
index 0000000..7724d9e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/RateLimitUploadPackIT.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import com.google.gerrit.acceptance.GlobalPluginConfig;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.junit.Test;
+
+@TestPlugin(
+    name = "rate-limiter",
+    sysModule = "com.googlesource.gerrit.plugins.ratelimiter.Module",
+    sshModule = "com.googlesource.gerrit.plugins.ratelimiter.SshModule")
+public class RateLimitUploadPackIT extends LightweightPluginDaemonTest {
+
+  @Override
+  public void setUp() throws Exception {
+    // Create the group before the plugin is loaded since limits per group are
+    // resolved at plugin load time.
+    addUserToNewGroup("user", "limitGroup");
+    super.setUp();
+  }
+
+  @Test
+  @UseLocalDisk
+  @GlobalPluginConfig(
+      pluginName = "rate-limiter",
+      name = "group.limitGroup.uploadpackperhour",
+      value = "1")
+  @GlobalPluginConfig(
+      pluginName = "rate-limiter",
+      name = "configuration.uploadpackLimitExceededMsg",
+      value = "Custom message: Limit exceeded ${rateLimit} requests/hour")
+  public void requestIsBlockedForGroupAfterRateLimitReached() throws Exception {
+    String projectA = "projectA";
+    String projectB = "projectB";
+    createProjectWithChange(projectA);
+    createProjectWithChange(projectB);
+
+    cloneProject(new Project.NameKey(projectA), user);
+    exception.expect(TransportException.class);
+    cloneProject(new Project.NameKey(projectB), user);
+  }
+
+  void addUserToNewGroup(String user, String groupName) throws RestApiException {
+    GroupInput in = new GroupInput();
+    in.name = groupName;
+    in.ownerId = "Administrators";
+    gApi.groups().create(in);
+    gApi.groups().id(groupName).addMembers(user);
+  }
+
+  void createProjectWithChange(String projectName) throws RestApiException {
+    ProjectInput input = new ProjectInput();
+    input.name = projectName;
+    input.createEmptyCommit = true;
+    gApi.projects().create(input);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyRateLimiterTest.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyRateLimiterTest.java
new file mode 100644
index 0000000..a3b56d1
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyRateLimiterTest.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+public class WarningHourlyRateLimiterTest {
+
+  private static final int RATE = 1000;
+  private static final int WARN_RATE = 900;
+  private WarningHourlyRateLimiter warningLimiter1;
+  private WarningHourlyRateLimiter warningLimiter2;
+  private ScheduledExecutorService scheduledExecutorMock1;
+  private UserResolver userResolver = mock(UserResolver.class);
+
+  @Before
+  public void setUp() {
+    scheduledExecutorMock1 = mock(ScheduledExecutorService.class);
+
+    ScheduledExecutorService scheduledExecutorMock2 = mock(ScheduledExecutorService.class);
+
+    HourlyRateLimiter limiter1 = spy(new HourlyRateLimiter(scheduledExecutorMock1, RATE));
+    doReturn(1L).when(limiter1).remainingTime(any(TimeUnit.class));
+
+    HourlyRateLimiter limiter2 = spy(new HourlyRateLimiter(scheduledExecutorMock2, RATE));
+    doReturn(1L).when(limiter2).remainingTime(any(TimeUnit.class));
+
+    warningLimiter1 = new WarningHourlyRateLimiter(userResolver, limiter1, "dummy", WARN_RATE);
+    warningLimiter2 = new WarningHourlyRateLimiter(userResolver, limiter2, "dummy2", WARN_RATE);
+  }
+
+  @Test
+  public void testGetRatePerHour() {
+    assertThat(warningLimiter1.permitsPerHour()).isEqualTo(RATE);
+  }
+
+  @Test
+  public void testAcquireAll() {
+    assertThat(warningLimiter1.availablePermits()).isEqualTo(RATE);
+
+    for (int permitNum = 1; permitNum <= RATE; permitNum++) {
+      checkGetPermitPasses(warningLimiter1, permitNum);
+    }
+    checkGetPermitFails(warningLimiter1);
+  }
+
+  @Test
+  public void testAcquireWarning() {
+    assertThat(warningLimiter2.availablePermits()).isEqualTo(RATE);
+
+    for (int permitNum = 1; permitNum < WARN_RATE; permitNum++) {
+      checkGetPermitPasses(warningLimiter2, permitNum);
+    }
+    // Check that the warning has not yet been triggered
+    assertThat(warningLimiter2.getWarningFlagState()).isFalse();
+
+    // Trigger the warning
+    assertThat(warningLimiter2.acquirePermit()).isTrue();
+    assertThat(warningLimiter2.getWarningFlagState()).isTrue();
+
+    for (int permitNum = WARN_RATE + 1; permitNum <= RATE; permitNum++) {
+      checkGetPermitPasses(warningLimiter2, permitNum);
+    }
+    checkGetPermitFails(warningLimiter2);
+  }
+
+  @Test
+  public void testReplenishPermitsIsScheduled() {
+    verify(scheduledExecutorMock1).scheduleAtFixedRate(any(), eq(1L), eq(1L), eq(TimeUnit.HOURS));
+  }
+
+  @Test
+  public void testReplenishPermitsScheduledRunnableIsWorking() {
+    ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
+    verify(scheduledExecutorMock1)
+        .scheduleAtFixedRate(runnableCaptor.capture(), eq(1L), eq(1L), eq(TimeUnit.HOURS));
+
+    replenishPermits(warningLimiter1, runnableCaptor);
+    testAcquireAll();
+
+    // Check the available permits are used up
+    assertThat(warningLimiter1.availablePermits()).isEqualTo(0);
+
+    replenishPermits(warningLimiter1, runnableCaptor);
+  }
+
+  private void checkGetPermitPasses(RateLimiter rateLimiter, int permitNum) {
+    assertThat(rateLimiter.acquirePermit()).isTrue();
+    assertThat(rateLimiter.availablePermits()).isEqualTo(RATE - permitNum);
+  }
+
+  private void checkGetPermitFails(RateLimiter rateLimiter) {
+    assertThat(rateLimiter.acquirePermit()).isFalse();
+    assertThat(rateLimiter.availablePermits()).isEqualTo(0);
+  }
+
+  private void replenishPermits(RateLimiter rateLimiter, ArgumentCaptor<Runnable> task) {
+    task.getValue().run();
+    assertThat(rateLimiter.availablePermits()).isEqualTo(RATE);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyUnlimitedRateLimiterTest.java b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyUnlimitedRateLimiterTest.java
new file mode 100644
index 0000000..f2abebf
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/ratelimiter/WarningHourlyUnlimitedRateLimiterTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2017 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.ratelimiter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+public class WarningHourlyUnlimitedRateLimiterTest {
+
+  private static final int RATE = 1000;
+  private static final int WARN_RATE = 900;
+  private WarningHourlyUnlimitedRateLimiter warningUnlimitedLimiter;
+  private ScheduledExecutorService scheduledExecutorMock;
+  private UserResolver userResolver = mock(UserResolver.class);
+
+  @Before
+  public void setUp() {
+    scheduledExecutorMock = mock(ScheduledExecutorService.class);
+    HourlyRateLimiter limiter = new HourlyRateLimiter(scheduledExecutorMock, RATE);
+    warningUnlimitedLimiter =
+        new WarningHourlyUnlimitedRateLimiter(userResolver, limiter, "dummy", WARN_RATE);
+  }
+
+  @Test
+  public void testGetRatePerHour() {
+    assertThat(warningUnlimitedLimiter.permitsPerHour()).isEqualTo(Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void testTriggerWarning() {
+    assertThat(warningUnlimitedLimiter.availablePermits()).isEqualTo(Integer.MAX_VALUE);
+
+    for (int i = 1; i < WARN_RATE; i++) {
+      assertThat(warningUnlimitedLimiter.acquirePermit()).isTrue();
+      assertThat(warningUnlimitedLimiter.availablePermits()).isEqualTo(Integer.MAX_VALUE);
+    }
+    // Check that the warning has not yet been triggered
+    assertThat(warningUnlimitedLimiter.getWarningFlagState()).isFalse();
+
+    // Trigger the warning
+    assertThat(warningUnlimitedLimiter.acquirePermit()).isTrue();
+    assertThat(warningUnlimitedLimiter.getWarningFlagState()).isTrue();
+
+    // Check there still is no limit
+    assertThat(warningUnlimitedLimiter.availablePermits()).isEqualTo(Integer.MAX_VALUE);
+  }
+
+  @Test
+  public void testReplenishPermitsIsScheduled() {
+    verify(scheduledExecutorMock).scheduleAtFixedRate(any(), eq(1L), eq(1L), eq(TimeUnit.HOURS));
+  }
+
+  @Test
+  public void testReplenishPermitsScheduledRunnableIsWorking() {
+    ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
+    verify(scheduledExecutorMock)
+        .scheduleAtFixedRate(runnableCaptor.capture(), eq(1L), eq(1L), eq(TimeUnit.HOURS));
+
+    testTriggerWarning();
+
+    // Check there is still an unlimited number of permits
+    assertThat(warningUnlimitedLimiter.availablePermits()).isEqualTo(Integer.MAX_VALUE);
+
+    // Replenishes the permits and clears count for warning
+    runnableCaptor.getValue().run();
+    assertThat(warningUnlimitedLimiter.availablePermits()).isEqualTo(Integer.MAX_VALUE);
+    assertThat(warningUnlimitedLimiter.usedPermits()).isEqualTo(0);
+  }
+}
diff --git a/tools/bazel.rc b/tools/bazel.rc
new file mode 100644
index 0000000..4ed16cf
--- /dev/null
+++ b/tools/bazel.rc
@@ -0,0 +1,2 @@
+build --workspace_status_command=./tools/workspace-status.sh
+test --build_tests_only
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tools/bzl/BUILD
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
new file mode 100644
index 0000000..d5764f7
--- /dev/null
+++ b/tools/bzl/classpath.bzl
@@ -0,0 +1,4 @@
+load(
+    "@com_googlesource_gerrit_bazlets//tools:classpath.bzl",
+    "classpath_collector",
+)
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
new file mode 100644
index 0000000..3af7e58
--- /dev/null
+++ b/tools/bzl/junit.bzl
@@ -0,0 +1,4 @@
+load(
+    "@com_googlesource_gerrit_bazlets//tools:junit.bzl",
+    "junit_tests",
+)
diff --git a/tools/bzl/maven_jar.bzl b/tools/bzl/maven_jar.bzl
new file mode 100644
index 0000000..2eabedb
--- /dev/null
+++ b/tools/bzl/maven_jar.bzl
@@ -0,0 +1 @@
+load("@com_googlesource_gerrit_bazlets//tools:maven_jar.bzl", "maven_jar")
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
new file mode 100644
index 0000000..0b25d23
--- /dev/null
+++ b/tools/bzl/plugin.bzl
@@ -0,0 +1,6 @@
+load(
+    "@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl",
+    "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
+    "gerrit_plugin",
+)
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
new file mode 100644
index 0000000..4b5b3a5
--- /dev/null
+++ b/tools/eclipse/BUILD
@@ -0,0 +1,9 @@
+load("//tools/bzl:classpath.bzl", "classpath_collector")
+
+classpath_collector(
+    name = "main_classpath_collect",
+    testonly = 1,
+    deps = [
+        "//:rate-limiter__plugin_test_deps",
+    ],
+)
diff --git a/tools/eclipse/project.sh b/tools/eclipse/project.sh
new file mode 100755
index 0000000..02e0cfd
--- /dev/null
+++ b/tools/eclipse/project.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# Copyright (C) 2017 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.
+
+`bazel query @com_googlesource_gerrit_bazlets//tools/eclipse:project --output location | sed s/BUILD:.*//`project.py -n rate-limiter -r .
diff --git a/tools/sonar/sonar.sh b/tools/sonar/sonar.sh
new file mode 100755
index 0000000..8df06d3
--- /dev/null
+++ b/tools/sonar/sonar.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# Copyright (C) 2018 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.
+
+`bazel query @com_googlesource_gerrit_bazlets//tools/sonar:sonar --output location | sed s/BUILD:.*//`sonar.py
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh
new file mode 100755
index 0000000..17048ab
--- /dev/null
+++ b/tools/workspace-status.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+# This script will be run by bazel when the build process starts to
+# generate key-value information that represents the status of the
+# workspace. The output should be like
+#
+# KEY1 VALUE1
+# KEY2 VALUE2
+#
+# If the script exits with non-zero code, it's considered as a failure
+# and the output will be discarded.
+
+function rev() {
+  cd $1 && git describe --always --match "v[0-9].*" --dirty
+}
+
+echo STABLE_BUILD_RATE-LIMITER_LABEL "$(rev .)"