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"]