First version of the scripting rules

TODO:
- Write documentation for the JS engine
- Does the JS engine need more cleanup?
- Add tests for the JS engine
- Add an integration test to ensure the module is properly setup

Change-Id: I5eafb912948e5c41d10df2aa9659f9c4bd5f25da
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..8264ec5
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,19 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("//tools/bzl:plugin.bzl", "gerrit_plugin", "PLUGIN_DEPS", "PLUGIN_TEST_DEPS")
+load("//plugins/scripting-rules:plugin.bzl", "SELF_PREFIX")
+
+gerrit_plugin(
+    name = "scripting-rules",
+    srcs = [
+        "java/com/googlesource/gerrit/plugins/scripting/rules/Module.java",
+    ],
+    manifest_entries = [
+        "Gerrit-PluginName: scripted-rules",
+        "Gerrit-Module: com.googlesource.gerrit.plugins.scripting.rules.Module",
+        "Gerrit-BatchModule: com.googlesource.gerrit.plugins.scripting.rules.Module",
+    ],
+    resources = glob(["resources/**/*"]),
+    deps = [
+        SELF_PREFIX + "/engines:module",
+    ],
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..47322ce
--- /dev/null
+++ b/README.md
@@ -0,0 +1,80 @@
+# Scripting Rules
+
+## Intro
+This plugin is an experimentation, and as such, no guarantees are offered regarding its future.
+
+The objective of this plugin is to simplify the step of defining custom submit rules for project
+owners who don't have administrative privileges. This repository contains a framework making it
+easier to write **scripting engines**, but the exact definition of an engine is still blurry.
+Ideally, writing a Prolog engine from scratch should be easier thanks to this plugin.
+
+The first engine provided will allow rules to be written in **JavaScript**. Communication between
+the rules and the users relies on submit requirements.
+
+## Developer's toolbox
+This project relies on the Bazel build system, just like the rest of the Gerrit project.
+
+In order to use the code of this plugin, clone the project in your local clone of Gerrit, inside of
+the `plugins/scripting-rules` directory. You then need to copy the `external_plugin_deps.bzl` file
+from this repository inside the `plugins/` directory.
+
+```
+~/gerrit# cd plugins
+# Clone the project
+~/gerrit/plugins# git clone https://gerrit.googlesource.com/plugins/scripting-rules
+~/gerrit/plugins# cd scripting-rules
+# Copy the external_plugin_deps.bzl file
+~/gerrit/plugins/scripting-rules# cp external_plugin_deps.bzl ../
+# Setup the Change-Id git hook.
+~/gerrit/plugins/scripting-rules# f=`git rev-parse --git-dir`/hooks/commit-msg
+~/gerrit/plugins/scripting-rules# curl -Lo $f https://gerrit-review.googlesource.com/tools/hooks/commit-msg
+~/gerrit/plugins/scripting-rules# chmod +x $f
+```
+
+### Compile
+To build this projecy, use the `bazel build //plugins/scripting-rules` command.
+
+```
+~/gerrit # bazel build //plugins/scripting-rules
+Starting local Bazel server and connecting to it...
+...........
+INFO: Analysed target //plugins/scripting-rules:scripting-rules (169 packages loaded).
+INFO: Found 1 target...
+Target //plugins/scripting-rules:scripting-rules up-to-date:
+  bazel-genfiles/plugins/scripting-rules/scripting-rules.jar
+INFO: Elapsed time: 11.823s, Critical Path: 3.97s
+INFO: 82 processes: 77 remote cache hit, 3 linux-sandbox, 2 worker.
+INFO: Build completed successfully, 90 total actions
+```
+
+The target is the plugin's jar file, in this case it is stored in
+`bazel-genfiles/plugins/scripting-rules/scripting-rules.jar`.
+
+### Test
+To run all the tests, use the `bazel test //plugins/scripting-rules/...` command.
+
+```asciidoc
+~/gerrit # bazel test //plugins/scripting-rules/...
+INFO: Analysed 2 targets (76 packages loaded).
+INFO: Found 2 test targets...
+INFO: Elapsed time: 2.823s, Critical Path: 1.43s
+INFO: 40 processes: 35 remote cache hit, 3 linux-sandbox, 2 worker.
+INFO: Build completed successfully, 43 total actions
+//plugins/scripting-rules/javatests/com/googlesource/gerrit/plugins/scripting/rules/engines:engines PASSED in 0.2s
+//plugins/scripting-rules/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils:utils PASSED in 0.5s
+
+Executed 2 out of 2 tests: 2 tests pass.
+INFO: Build completed successfully, 43 total actions
+```
+
+### Adding an engine
+Engines are defined in the
+`plugins/scripting-rules/java/com/googlesource/gerrit/plugins/scripting/rules/engines/` directory,
+and must implement the `com.googlesource.gerrit.plugins.scripting.rules.engines.RuleEngine` class.
+
+The engine name (which is the directory name) should also be enabled in the `plugin.bzl` file. This
+extra step makes it easier to enable or completely disable engines on the fly.
+
+In order to be used, the Engine must be declared in the `EnginesModule` file, either by installing a
+module or by adding the engine to the DynamicSet:
+`DynamicSet.bind(binder(), RuleEngine.class).to(MyEngineName.class);`
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl
new file mode 100644
index 0000000..bc3a35f
--- /dev/null
+++ b/external_plugin_deps.bzl
@@ -0,0 +1,10 @@
+# Move me to <Gerrit's code root>/plugins/external_plugin_deps.bzl
+
+load("//tools/bzl:maven_jar.bzl", "maven_jar")
+
+def external_plugin_deps():
+    maven_jar(
+        name = "com_eclipsesource_j2v8",
+        artifact = "com.eclipsesource.j2v8:j2v8_linux_x86_64:4.8.0",
+        sha1 = "dad0e7695388f99ab504fa9f259101394a78eb2f",
+    )
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/Module.java b/java/com/googlesource/gerrit/plugins/scripting/rules/Module.java
new file mode 100644
index 0000000..13a746e
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/Module.java
@@ -0,0 +1,26 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules;
+
+import com.google.inject.AbstractModule;
+import com.googlesource.gerrit.plugins.scripting.rules.engines.EnginesModule;
+
+/** Bootstraps the Simple Submit Rules plugin */
+public class Module extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(new EnginesModule());
+  }
+}
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/engines/BUILD b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/BUILD
new file mode 100644
index 0000000..1b7ff67
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/BUILD
@@ -0,0 +1,28 @@
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+load("//tools/bzl:plugin.bzl", "gerrit_plugin", "PLUGIN_DEPS_NEVERLINK")
+load("//plugins/scripting-rules:plugin.bzl", "SELF_PREFIX", "ENGINES_TO_ENABLE")
+
+java_library(
+    name = "engines",
+    srcs = glob(["RuleEngine.java"]),
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        SELF_PREFIX + "/utils",
+    ],
+)
+
+ENGINES_LABELS = [
+    SELF_PREFIX + "/engines/" + name
+    for name in ENGINES_TO_ENABLE
+]
+
+java_library(
+    name = "module",
+    srcs = ["EnginesModule.java"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        ":engines",
+        SELF_PREFIX + "/rule",
+    ] + ENGINES_LABELS,
+)
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/engines/EnginesModule.java b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/EnginesModule.java
new file mode 100644
index 0000000..b239bbb
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/EnginesModule.java
@@ -0,0 +1,32 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.engines;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.inject.AbstractModule;
+import com.googlesource.gerrit.plugins.scripting.rules.engines.js.JsEngineModule;
+import com.googlesource.gerrit.plugins.scripting.rules.rule.ScriptedRule;
+
+/** Rules for the batch programs (compatible with the offline reindexer) */
+public class EnginesModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), SubmitRule.class).to(ScriptedRule.class);
+    DynamicSet.setOf(binder(), RuleEngine.class);
+
+    install(new JsEngineModule());
+  }
+}
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/engines/RuleEngine.java b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/RuleEngine.java
new file mode 100644
index 0000000..f118645
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/RuleEngine.java
@@ -0,0 +1,34 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.engines;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.project.RuleEvalException;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.googlesource.gerrit.plugins.scripting.rules.utils.FileFinder;
+import java.io.IOException;
+import java.util.Collection;
+
+/** Defines a scripting engine, called when a change must be evaluated. */
+public interface RuleEngine {
+  @Nullable
+  Collection<SubmitRecord> evaluate(
+      ChangeData cd, Change change, SubmitRuleOptions opts, FileFinder fileFinder)
+      throws IOException, OrmException, RuleEvalException;
+}
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/BUILD b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/BUILD
new file mode 100644
index 0000000..f2f2a87
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/BUILD
@@ -0,0 +1,16 @@
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+load("//tools/bzl:plugin.bzl", "gerrit_plugin", "PLUGIN_DEPS_NEVERLINK")
+load("//plugins/scripting-rules:plugin.bzl", "SELF_PREFIX", "ENGINES_TO_ENABLE")
+
+java_library(
+    name = "js",
+    srcs = glob(["**/*.java"]),
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        "@com_eclipsesource_j2v8//jar",
+        SELF_PREFIX + "/engines",
+        SELF_PREFIX + "/utils",
+    ],
+)
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/JsEngineModule.java b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/JsEngineModule.java
new file mode 100644
index 0000000..4b68235
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/JsEngineModule.java
@@ -0,0 +1,26 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.engines.js;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.AbstractModule;
+import com.googlesource.gerrit.plugins.scripting.rules.engines.RuleEngine;
+
+public class JsEngineModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), RuleEngine.class).to(JsRuleEngine.class);
+  }
+}
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/JsRuleEngine.java b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/JsRuleEngine.java
new file mode 100644
index 0000000..7a83700
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/engines/js/JsRuleEngine.java
@@ -0,0 +1,292 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.engines.js;
+
+import com.eclipsesource.v8.JavaCallback;
+import com.eclipsesource.v8.V8;
+import com.eclipsesource.v8.V8Array;
+import com.eclipsesource.v8.V8Object;
+import com.eclipsesource.v8.V8RuntimeException;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.common.data.SubmitRecord.Status;
+import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.project.RuleEvalException;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.googlesource.gerrit.plugins.scripting.rules.engines.RuleEngine;
+import com.googlesource.gerrit.plugins.scripting.rules.utils.FileFinder;
+import com.googlesource.gerrit.plugins.scripting.rules.utils.ThrowingSupplier;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.PersonIdent;
+
+class JsRuleEngine implements RuleEngine {
+  private static final String SLOW_RULE = "Rule execution did not terminate in time";
+  private static final long TIMEOUT_DELAY = 300;
+  private final AccountCache accountCache;
+
+  @Inject
+  private JsRuleEngine(AccountCache accountCache) {
+    this.accountCache = accountCache;
+  }
+
+  @Override
+  public Collection<SubmitRecord> evaluate(
+      ChangeData cd, Change change, SubmitRuleOptions opts, FileFinder fileFinder)
+      throws IOException, OrmException, RuleEvalException {
+    if (!fileFinder.pointAtMetaConfig()) {
+      // The refs/meta/config branch does not exist
+      return null;
+    }
+
+    String jsRules;
+    try {
+      jsRules = fileFinder.readFile("rules.js");
+    } catch (IOException e) {
+      throw new RuleEvalException("Could not read rules.js", e);
+    }
+
+    if (jsRules == null) {
+      // The rules.js file does not exist
+      return null;
+    }
+
+    try {
+      return runScriptInSandbox(jsRules, change, cd);
+    } catch (V8RuntimeException e) {
+      SubmitRecord errorRecord = new SubmitRecord();
+      errorRecord.status = Status.RULE_ERROR;
+      errorRecord.requirements =
+          ImmutableList.of(
+              SubmitRequirement.builder()
+                  .setFallbackText("Fix the rules.js file!")
+                  .setType("rules_js_invalid")
+                  .build());
+      if (opts.logErrors() || true) {
+        e.printStackTrace();
+      }
+      return ImmutableList.of(errorRecord);
+    }
+  }
+
+  private Collection<SubmitRecord> runScriptInSandbox(String script, Change change, ChangeData cd)
+      throws IOException, OrmException {
+    V8 v8 = V8.createV8Runtime();
+    try {
+      final AtomicBoolean finished = new AtomicBoolean(false);
+
+      // Setup the Requirement prototype
+      v8.executeVoidScript(
+          "function Requirement(is_met, description) {\n"
+              + "this.is_met = is_met;\n"
+              + "this.description = description;\n"
+              + "};");
+      startWatchdog(v8, finished);
+
+      v8.executeScript(script, change.getProject().get() + ":/rules.js", 0);
+      if (finished.get()) {
+        throw new RuntimeException(SLOW_RULE);
+      }
+
+      V8Object v8Change = prepareChangeObject(v8, change, cd);
+      V8Array v8Requirements = new V8Array(v8);
+
+      try {
+        v8.executeJSFunction("submit_rule", v8Change, v8Requirements);
+
+        if (finished.getAndSet(true)) {
+          throw new RuntimeException(SLOW_RULE);
+        }
+
+        if (v8Requirements.length() == 0) {
+          // The script did not add any requirements.
+          return null;
+        }
+
+        // We don't want to return records with zero requirements.
+        return parseResults(v8Requirements)
+            .stream()
+            .filter(s -> !s.requirements.isEmpty())
+            .collect(Collectors.toList());
+      } finally {
+        v8Change.release();
+        v8Requirements.release();
+      }
+    } finally {
+      v8.release();
+    }
+  }
+
+  private Collection<SubmitRecord> parseResults(V8Array v8Requirements) {
+    SubmitRecord okRequirements = new SubmitRecord();
+    okRequirements.status = Status.OK;
+    okRequirements.requirements = new ArrayList<>();
+
+    SubmitRecord notReadyRequirements = new SubmitRecord();
+    notReadyRequirements.status = Status.NOT_READY;
+    notReadyRequirements.requirements = new ArrayList<>();
+
+    for (int i = 0; i < v8Requirements.length(); i++) {
+      V8Object v8Requirement = v8Requirements.getObject(i);
+      SubmitRequirement requirement =
+          SubmitRequirement.builder()
+              .setFallbackText(v8Requirement.getString("description"))
+              .setType("rules_js")
+              .build();
+      boolean isMet = v8Requirement.getBoolean("is_met");
+      if (!isMet) {
+        notReadyRequirements.requirements.add(requirement);
+      } else {
+        okRequirements.requirements.add(requirement);
+      }
+      v8Requirement.release();
+    }
+    return ImmutableList.of(okRequirements, notReadyRequirements);
+  }
+
+  private void startWatchdog(V8 v8, AtomicBoolean finished) {
+    new Thread(
+            () -> {
+              try {
+                Thread.sleep(TIMEOUT_DELAY);
+              } catch (InterruptedException e) {
+                return;
+              }
+              if (!finished.getAndSet(true)) {
+                v8.terminateExecution();
+              }
+            })
+        .start();
+  }
+
+  private V8Object prepareChangeObject(final V8 v8, Change change, ChangeData cd)
+      throws IOException, OrmException {
+    V8Object v8Change = new V8Object(v8);
+
+    v8Change.registerJavaMethod(exposePersonIdent(v8, cd.getAuthor()), "author");
+    v8Change.registerJavaMethod(exposePersonIdent(v8, cd.getCommitter()), "committer");
+
+    defineProperty(v8Change, cd::unresolvedCommentCount, "unresolved_comments_count");
+    defineProperty(v8Change, change::isPrivate, "private");
+    defineProperty(v8Change, change::isWorkInProgress, "work_in_progress");
+    defineProperty(v8Change, change::isWorkInProgress, "wip");
+    defineProperty(v8Change, change::getSubject, "subject");
+    defineProperty(v8Change, cd.currentPatchSet()::getRefName, "branch");
+
+    v8Change.registerJavaMethod(findVotes(v8, cd.currentApprovals()), "findVotes");
+
+    return v8Change;
+  }
+
+  private JavaCallback findVotes(V8 v8, List<PatchSetApproval> patchSetApprovals) {
+    return (receiver, parameters) -> {
+      String label = parameters.getString(0);
+      Integer value = parameters.length() >= 2 ? parameters.getInteger(1) : null;
+      V8Array v8Votes = new V8Array(v8);
+
+      for (PatchSetApproval approval : patchSetApprovals) {
+        if (!label.equalsIgnoreCase(approval.getLabel())) {
+          continue;
+        }
+        if (value != null && value != approval.getValue()) {
+          continue;
+        }
+        V8Object v8Author = new V8Object(v8);
+
+        v8Author.add("label", approval.getLabel());
+        v8Author.add("value", approval.getValue());
+        v8Author.add("patchset_id", approval.getPatchSetId().patchSetId);
+
+        V8Object v8Account = new V8Object(v8);
+        v8Author.add("account", v8Account);
+        AccountState account = accountCache.getEvenIfMissing(approval.getAccountId());
+
+        v8Account.registerJavaMethod(
+            new JavaCallback() {
+              @Override
+              public Object invoke(V8Object receiver, V8Array parameters) {
+                try {
+                  String emailToCheck = parameters.getString(0);
+                  for (ExternalId extId : account.getExternalIds()) {
+                    if (emailToCheck.equalsIgnoreCase(extId.email())) {
+                      return true;
+                    }
+                  }
+                  return false;
+                } catch (Exception e) {
+                  return null;
+                }
+              }
+            },
+            "hasEmail");
+
+        v8Votes.push(v8Author);
+        v8Author.release();
+        v8Account.release();
+      }
+
+      return v8Votes;
+    };
+  }
+
+  private void defineProperty(
+      V8Object myObject, ThrowingSupplier<?, ?> supplier, String methodName) {
+    V8 v8 = myObject.getRuntime();
+    V8Object methodProperty = new V8Object(v8);
+    methodProperty.registerJavaMethod(
+        new JavaCallback() {
+          @Override
+          public Object invoke(V8Object receiver, V8Array parameters) {
+            try {
+              return supplier.get();
+            } catch (Exception e) {
+              throw new RuntimeException(e);
+            }
+          }
+        },
+        "get");
+
+    V8Object object = v8.getObject("Object");
+    V8Object ret =
+        (V8Object) object.executeJSFunction("defineProperty", myObject, methodName, methodProperty);
+
+    object.release();
+    ret.release();
+    methodProperty.release();
+  }
+
+  private JavaCallback exposePersonIdent(V8 v8, PersonIdent personIdentSupplier) {
+    return (receiver, parameters) -> {
+      PersonIdent author;
+      author = personIdentSupplier;
+      V8Object v8Author = new V8Object(v8);
+      v8Author.add("email", author.getEmailAddress());
+      v8Author.add("name", author.getName());
+      return v8Author;
+    };
+  }
+}
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/rule/BUILD b/java/com/googlesource/gerrit/plugins/scripting/rules/rule/BUILD
new file mode 100644
index 0000000..f27b47e
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/rule/BUILD
@@ -0,0 +1,15 @@
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+load("//plugins/scripting-rules:plugin.bzl", "SELF_PREFIX", "ENGINES_TO_ENABLE")
+
+java_library(
+    name = "rule",
+    srcs = ["ScriptedRule.java"],
+    deps = PLUGIN_DEPS_NEVERLINK + [
+        SELF_PREFIX + "/utils",
+        SELF_PREFIX + "/engines",
+    ],
+)
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/rule/ScriptedRule.java b/java/com/googlesource/gerrit/plugins/scripting/rules/rule/ScriptedRule.java
new file mode 100644
index 0000000..688c578
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/rule/ScriptedRule.java
@@ -0,0 +1,94 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.rule;
+
+import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.RuleEvalException;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.scripting.rules.engines.RuleEngine;
+import com.googlesource.gerrit.plugins.scripting.rules.utils.FileFinder;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.lib.Repository;
+
+/** This SubmitRule runs the scripting engines it knows about. */
+@Singleton
+public class ScriptedRule implements SubmitRule {
+  private final GitRepositoryManager gitMgr;
+  private final DynamicSet<RuleEngine> engines;
+
+  @Inject
+  private ScriptedRule(GitRepositoryManager gitMgr, DynamicSet<RuleEngine> engines) {
+    this.gitMgr = gitMgr;
+    this.engines = engines;
+  }
+
+  @Override
+  public Collection<SubmitRecord> evaluate(ChangeData cd, SubmitRuleOptions options) {
+    try (Repository git = gitMgr.openRepository(cd.project());
+        FileFinder fileFinder = new FileFinder(git)) {
+
+      Change change = cd.change();
+
+      return StreamSupport.stream(engines.spliterator(), false)
+          .map(new ScriptEvaluator(cd, change, options, fileFinder))
+          .filter(Objects::nonNull)
+          .flatMap(Collection::stream)
+          .collect(Collectors.toList());
+    } catch (OrmException | IOException e) {
+      e.printStackTrace();
+      return SubmitRuleEvaluator.createRuleError("Error in ScriptedRule");
+    }
+  }
+
+  /** Helper class to evaluate a scripting engine and catching its potential exceptions. */
+  private class ScriptEvaluator implements Function<RuleEngine, Collection<SubmitRecord>> {
+    private final ChangeData cd;
+    private final Change change;
+    private final SubmitRuleOptions options;
+    private final FileFinder fileFinder;
+
+    private ScriptEvaluator(
+        ChangeData cd, Change change, SubmitRuleOptions options, FileFinder fileFinder) {
+
+      this.cd = cd;
+      this.change = change;
+      this.options = options;
+      this.fileFinder = fileFinder;
+    }
+
+    @Override
+    public Collection<SubmitRecord> apply(RuleEngine ruleEngine) {
+      try {
+        return ruleEngine.evaluate(cd, change, options, fileFinder);
+      } catch (IOException | OrmException | RuleEvalException e) {
+        return SubmitRuleEvaluator.createRuleError("Error evaluating the rules");
+      }
+    }
+  }
+}
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/utils/BUILD b/java/com/googlesource/gerrit/plugins/scripting/rules/utils/BUILD
new file mode 100644
index 0000000..8fb3dcc
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/utils/BUILD
@@ -0,0 +1,11 @@
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+load("//tools/bzl:plugin.bzl", "PLUGIN_DEPS_NEVERLINK")
+
+java_library(
+    name = "utils",
+    srcs = glob(["*.java"]),
+    deps = PLUGIN_DEPS_NEVERLINK,
+)
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/utils/FileFinder.java b/java/com/googlesource/gerrit/plugins/scripting/rules/utils/FileFinder.java
new file mode 100644
index 0000000..72cf833
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/utils/FileFinder.java
@@ -0,0 +1,131 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.utils;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Utility class to load several files from a repository, without opening it multiple times. See
+ * {@link ThrowingSupplierTest} for examples.
+ *
+ * <p>This class is not thread-safe.
+ */
+public class FileFinder implements AutoCloseable {
+  private RevCommit revision;
+  private final ObjectReader reader;
+  private final RevWalk walk;
+  private final Repository git;
+
+  public FileFinder(Repository git) {
+    this.git = git;
+
+    walk = new RevWalk(git);
+    reader = walk.getObjectReader();
+  }
+
+  @Override
+  public void close() {
+    this.revision = null;
+    walk.close();
+    reader.close();
+  }
+
+  /** Returns the content of a file, at the current revision.e. */
+  @Nullable
+  public String readFile(String fileName) throws IOException {
+    ObjectId objectId = findFile(fileName);
+    if (objectId == null) {
+      return null;
+    }
+
+    ObjectLoader obj = reader.open(objectId, Constants.OBJ_BLOB);
+    byte[] raw = obj.getCachedBytes(Integer.MAX_VALUE);
+
+    if (raw.length == 0) {
+      return null;
+    }
+    return RawParseUtils.decode(raw);
+  }
+
+  /** Returns the object id for a given filename, at the current revision. */
+  @Nullable
+  public ObjectId findFile(String fileName) throws IOException {
+    if (revision == null) {
+      return null;
+    }
+
+    try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
+      if (tw != null) {
+        return tw.getObjectId(0);
+      }
+    }
+    return null;
+  }
+
+  /** Places the pointer at refs/head/master's head. */
+  public boolean pointAtMaster() {
+    return pointAt(RefNames.fullName("master"));
+  }
+
+  /** Places the pointer at refs/meta/config's head. */
+  public boolean pointAtMetaConfig() {
+    return pointAt(RefNames.REFS_CONFIG);
+  }
+
+  /** Places the pointer at the specified's ref head. */
+  public boolean pointAt(String refName) {
+    revision = null;
+
+    try {
+      Ref ref = git.getRefDatabase().exactRef(refName);
+      if (ref == null) {
+        return false;
+      }
+
+      revision = walk.parseCommit(ref.getObjectId());
+    } catch (IOException ignore) {
+    }
+
+    return revision != null;
+  }
+
+  private boolean pointAt(RevId revId) {
+    revision = null;
+
+    ObjectId id = ObjectId.fromString(revId.get());
+    if (id == null) {
+      return false;
+    }
+
+    try {
+      revision = walk.parseCommit(id);
+    } catch (IOException ignore) {
+    }
+    return revision != null;
+  }
+}
diff --git a/java/com/googlesource/gerrit/plugins/scripting/rules/utils/ThrowingSupplier.java b/java/com/googlesource/gerrit/plugins/scripting/rules/utils/ThrowingSupplier.java
new file mode 100644
index 0000000..5b12756
--- /dev/null
+++ b/java/com/googlesource/gerrit/plugins/scripting/rules/utils/ThrowingSupplier.java
@@ -0,0 +1,21 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.utils;
+
+/** A supplier allowed to throw (some) exceptions. */
+@FunctionalInterface
+public interface ThrowingSupplier<T, E extends Exception> {
+  T get() throws E;
+}
diff --git a/javatests/com/googlesource/gerrit/plugins/scripting/rules/engines/BUILD b/javatests/com/googlesource/gerrit/plugins/scripting/rules/engines/BUILD
new file mode 100644
index 0000000..699f9f8
--- /dev/null
+++ b/javatests/com/googlesource/gerrit/plugins/scripting/rules/engines/BUILD
@@ -0,0 +1,10 @@
+load("//tools/bzl:plugin.bzl", "gerrit_plugin", "PLUGIN_DEPS", "PLUGIN_TEST_DEPS")
+load("//plugins/scripting-rules:plugin.bzl", "SELF_PREFIX")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "engines",
+    srcs = glob(["**/*.java"]),
+    visibility = ["//visibility:public"],
+    deps = PLUGIN_TEST_DEPS + ["//plugins/scripting-rules"],
+)
diff --git a/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/BUILD b/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/BUILD
new file mode 100644
index 0000000..9ae16d4
--- /dev/null
+++ b/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/BUILD
@@ -0,0 +1,12 @@
+load("//tools/bzl:plugin.bzl", "gerrit_plugin", "PLUGIN_TEST_DEPS")
+load("//plugins/scripting-rules:plugin.bzl", "SELF_PREFIX")
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "utils",
+    srcs = glob(["**/*.java"]),
+    deps = PLUGIN_TEST_DEPS + [
+        "//plugins/scripting-rules",
+        SELF_PREFIX + "/utils",
+    ],
+)
diff --git a/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/FileFinderTest.java b/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/FileFinderTest.java
new file mode 100644
index 0000000..71f3915
--- /dev/null
+++ b/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/FileFinderTest.java
@@ -0,0 +1,115 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.utils;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.IOException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FileFinderTest {
+  private Repository git;
+  private TestRepository<Repository> repo;
+
+  @Before
+  public void setUp() throws Exception {
+    git = new InMemoryRepository(new DfsRepositoryDescription("test_repo"));
+    repo = new TestRepository<>(git);
+  }
+
+  @After
+  public void tearDown() {
+    git.close();
+  }
+
+  @Test
+  public void readFile() throws Exception {
+    repo.update("master", repo.commit().add("existant-file", "content"));
+
+    try (FileFinder fileFinder = new FileFinder(git)) {
+      boolean pointingWorked = fileFinder.pointAtMaster();
+      assertThat(pointingWorked).isTrue();
+
+      String content = fileFinder.readFile("existant-file");
+      assertThat(content).isEqualTo("content");
+    }
+  }
+
+  @Test
+  public void findsFileInMaster() throws Exception {
+    repo.update("master", repo.commit().add("existant-file", "content"));
+
+    try (FileFinder fileFinder = new FileFinder(git)) {
+      boolean pointingWorked = fileFinder.pointAtMaster();
+      assertThat(pointingWorked).isTrue();
+
+      ObjectId objectId = fileFinder.findFile("inexistant-file");
+      assertThat(objectId).isNull();
+
+      ObjectId existingId = fileFinder.findFile("existant-file");
+      assertThat(existingId).isNotNull();
+    }
+  }
+
+  @Test
+  public void pointAtMasterFailsWhenMasterBranchDoesNotExist() throws IOException {
+    try (FileFinder fileFinder = new FileFinder(git)) {
+      boolean pointingWorked = fileFinder.pointAtMaster();
+      assertThat(pointingWorked).isFalse();
+    }
+  }
+
+  @Test
+  public void pointAtWorksWhenBranchExists() throws Exception {
+    repo.update("master", repo.commit().add("existant-file", "content"));
+    try (FileFinder fileFinder = new FileFinder(git)) {
+      boolean pointingWorked = fileFinder.pointAt("refs/heads/master");
+      assertThat(pointingWorked).isTrue();
+    }
+  }
+
+  @Test
+  public void pointAtDoesIsNotConfusedByCommonPrefix() throws Exception {
+    repo.update("master/nope", repo.commit().add("existant-file", "content"));
+    try (FileFinder fileFinder = new FileFinder(git)) {
+      boolean pointingWorked = fileFinder.pointAt("refs/heads/master");
+      assertThat(pointingWorked).isFalse();
+    }
+  }
+
+  @Test
+  public void pointAtCanBeCalledAfterAFailure() throws Exception {
+    repo.update("master", repo.commit().add("existant-file", "content"));
+    boolean pointingWorked;
+
+    try (FileFinder fileFinder = new FileFinder(git)) {
+      pointingWorked = fileFinder.pointAtMaster();
+      assertThat(pointingWorked).isTrue();
+
+      pointingWorked = fileFinder.pointAtMetaConfig();
+      assertThat(pointingWorked).isFalse();
+
+      pointingWorked = fileFinder.pointAtMaster();
+      assertThat(pointingWorked).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/ThrowingSupplierTest.java b/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/ThrowingSupplierTest.java
new file mode 100644
index 0000000..d672efa
--- /dev/null
+++ b/javatests/com/googlesource/gerrit/plugins/scripting/rules/utils/ThrowingSupplierTest.java
@@ -0,0 +1,71 @@
+// 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.
+
+package com.googlesource.gerrit.plugins.scripting.rules.utils;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.IOException;
+import java.util.function.Supplier;
+import org.junit.Test;
+
+/**
+ * Small tests to showcase the benefit of using the ThrowingSupplier class. Without it, the lambda
+ * method is overly complex, and code needs to be duplicated (or extracted, for instance, in a class
+ * named ThrowingSupplier).
+ */
+public class ThrowingSupplierTest {
+
+  @Test
+  public void demoUsage() {
+    String message = doSomethingThrowingSupplier("Maxime", ThrowingSupplierTest::doWork);
+
+    assertThat(message).isEqualTo("Hello, Maxime");
+  }
+
+  @Test
+  public void sameDemoWithoutThrowingSupplier() {
+    String message =
+        doSomethingSupplier(
+            "Maxime",
+            () -> {
+              try {
+                return doWork();
+              } catch (IOException e) {
+                return "Unknown";
+              }
+            });
+
+    assertThat(message).isEqualTo("Hello, Maxime");
+  }
+
+  private static String doSomethingThrowingSupplier(
+      String name, ThrowingSupplier<String, IOException> method) {
+    try {
+      return method.get() + name;
+    } catch (IOException e) {
+      return "Unknown";
+    }
+  }
+
+  private static String doSomethingSupplier(String name, Supplier<String> method) {
+    return method.get() + name;
+  }
+
+  /** Simple method allowed to throw an exception */
+  @SuppressWarnings("RedundantThrows")
+  private static String doWork() throws IOException {
+    return "Hello, ";
+  }
+}
diff --git a/plugin.bzl b/plugin.bzl
new file mode 100644
index 0000000..7f868d7
--- /dev/null
+++ b/plugin.bzl
@@ -0,0 +1,3 @@
+SELF_PREFIX = "//plugins/scripting-rules/java/com/googlesource/gerrit/plugins/scripting/rules"
+
+ENGINES_TO_ENABLE = ["js"]