Merge branch 'stable-2.15' into stable-2.16

* stable-2.15:
  Upgrade bazlets to latest stable-2.15 to build with 2.15.9 API
  Upgrade bazlets to latest stable-2.14 to build with 2.14.18 API
  Do not fetch commit message for ref deletions
  Revert "Fix ref updated event on change deletion"
  Bazel: Include eclipse-out directory in .bazelignore
  Add explanatory comment to empty BUILD file(s)
  Upgrade bazlets to latest stable-2.15 to build with 2.15.7 API
  Upgrade bazlets to latest stable-2.14 to build with 2.14.17 API
  Upgrade bazlets to latest stable-2.15 to build with 2.15.6 API
  WORKSPACE: Make commented out local_path line spaces indent consistent
  Upgrade bazlets to latest stable-2.14 to build with 2.14.16 API
  Migrate `tools/bazel.rc` to `.bazelrc`
  Update build documentation to link to dev-bazel instead of dev-buck
  Align Eclipse compiler settings with core Gerrit's
  Upgrade bazlets to latest stable-2.15 to build with 2.15.5 API
  bazlets: Replace native.git_repository with skylark rule
  Upgrade bazlets to latest stable-2.14 to build with 2.14.15 API
  Add eclipse-out to .gitignore
  Format all build files with buildifier 0.15.0
  Update bazlets to latest revision on stable-2.15
  Remove commented-out code
  Format BUILD files with buildifier 0.12.0
  Format Java files with google-java-format 1.6
  Update bazlets to latest revision on stable-2.14

Change-Id: I49f57257660bcd01bc65a06a0af802980a4476fe
diff --git a/.gitignore b/.gitignore
index 83277ad..cabf34f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,5 @@
 .project
 /.settings
 /bazel-*
+.DS_Store
 /eclipse-out
diff --git a/WORKSPACE b/WORKSPACE
index 7610ede..60d9408 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -3,8 +3,8 @@
 load("//:bazlets.bzl", "load_bazlets")
 
 load_bazlets(
-    commit = "ca34f0cf89b2e041ea7f4aa4b9696efc2d76746f",
-    #local_path = "/home/<user>/projects/bazlets",
+    commit = "c2227415d5044f8439bd47edffb0f052f8da2ac5",
+    # local_path = "/home/<user>/projects/bazlets",
 )
 
 # Snapshot Plugin API
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/GlobalRulesFileName.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/GlobalRulesFileName.java
new file mode 100644
index 0000000..4d33168
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/GlobalRulesFileName.java
@@ -0,0 +1,24 @@
+// 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.its.base;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GlobalRulesFileName {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/ItsHookModule.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/ItsHookModule.java
index 91b26a0..dc6f4a5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/ItsHookModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/ItsHookModule.java
@@ -14,30 +14,43 @@
 
 package com.googlesource.gerrit.plugins.its.base;
 
-import com.google.gerrit.common.EventListener;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.inject.Inject;
+import com.google.inject.Provides;
 import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
 import com.googlesource.gerrit.plugins.its.base.its.ItsHookEnabledConfigEntry;
 import com.googlesource.gerrit.plugins.its.base.validation.ItsValidateComment;
 import com.googlesource.gerrit.plugins.its.base.workflow.ActionController;
 import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
+import com.googlesource.gerrit.plugins.its.base.workflow.AddComment;
+import com.googlesource.gerrit.plugins.its.base.workflow.AddPropertyToField;
+import com.googlesource.gerrit.plugins.its.base.workflow.AddSoyComment;
+import com.googlesource.gerrit.plugins.its.base.workflow.AddStandardComment;
 import com.googlesource.gerrit.plugins.its.base.workflow.Condition;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
+import com.googlesource.gerrit.plugins.its.base.workflow.CreateVersionFromProperty;
+import com.googlesource.gerrit.plugins.its.base.workflow.CustomAction;
+import com.googlesource.gerrit.plugins.its.base.workflow.ItsRulesProjectCacheImpl;
+import com.googlesource.gerrit.plugins.its.base.workflow.LogEvent;
 import com.googlesource.gerrit.plugins.its.base.workflow.Rule;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddSoyComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddStandardComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddVelocityComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.LogEvent;
+import java.nio.file.Path;
 
 public class ItsHookModule extends FactoryModule {
 
+  /** Rules configuration filename pattern */
+  private static final String CONFIG_FILE_NAME = "actions%s.config";
+
+  /** Folder where rules configuration files are located */
+  private static final String ITS_FOLDER = "its";
+
   private final String pluginName;
   private final PluginConfigFactory pluginCfgFactory;
 
@@ -55,13 +68,34 @@
     DynamicSet.bind(binder(), CommitValidationListener.class).to(ItsValidateComment.class);
     DynamicSet.bind(binder(), EventListener.class).to(ActionController.class);
     factory(ActionRequest.Factory.class);
-    factory(Property.Factory.class);
     factory(Condition.Factory.class);
     factory(Rule.Factory.class);
     factory(AddComment.Factory.class);
     factory(AddSoyComment.Factory.class);
     factory(AddStandardComment.Factory.class);
-    factory(AddVelocityComment.Factory.class);
+    factory(CreateVersionFromProperty.Factory.class);
     factory(LogEvent.Factory.class);
+    factory(AddPropertyToField.Factory.class);
+    DynamicMap.mapOf(binder(), CustomAction.class);
+    install(ItsRulesProjectCacheImpl.module());
+  }
+
+  @Provides
+  @ItsPath
+  @Inject
+  Path itsPath(SitePaths sitePaths) {
+    return sitePaths.etc_dir.normalize().resolve(ITS_FOLDER);
+  }
+
+  @Provides
+  @GlobalRulesFileName
+  String globalRulesFileName() {
+    return String.format(CONFIG_FILE_NAME, "");
+  }
+
+  @Provides
+  @PluginRulesFileName
+  String pluginRulesFileName() {
+    return String.format(CONFIG_FILE_NAME, "-" + pluginName);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/ItsPath.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/ItsPath.java
new file mode 100644
index 0000000..dfd5119
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/ItsPath.java
@@ -0,0 +1,24 @@
+// 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.its.base;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface ItsPath {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/PluginRulesFileName.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/PluginRulesFileName.java
new file mode 100644
index 0000000..e53b44b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/PluginRulesFileName.java
@@ -0,0 +1,24 @@
+// 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.its.base;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface PluginRulesFileName {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InitIts.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InitIts.java
index 1cd2b74..9fb2b3d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InitIts.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InitIts.java
@@ -21,7 +21,10 @@
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
 import java.io.IOException;
+import java.nio.file.Path;
 import java.util.EnumSet;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -41,6 +44,8 @@
     ENFORCED
   }
 
+  @Inject private SitePaths sitePaths;
+
   private final String pluginName;
   private final String itsDisplayName;
   protected final ConsoleUI ui;
@@ -61,7 +66,18 @@
   }
 
   @Override
-  public void run() throws IOException, ConfigInvalidException {}
+  public void run() throws IOException, ConfigInvalidException {
+    Path deprecatedRules = sitePaths.etc_dir.normalize().resolve("its").resolve("action.config");
+    if (deprecatedRules.toFile().exists()) {
+      ui.error(
+          "Deprecated rules file '%s' (No trailing 's' in 'action') will not be read. "
+              + "Please migrate to 'etc/its/actions.config' (Trailing 's' in 'actions') and retry "
+              + "the init step.\n",
+          deprecatedRules);
+      throw new ConfigInvalidException(
+          "Deprecated configuration file found: " + deprecatedRules.toRealPath());
+    }
+  }
 
   @Override
   public void postRun() throws IOException, ConfigInvalidException {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InvalidTransitionException.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InvalidTransitionException.java
index 78c6ec5..949031b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InvalidTransitionException.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/InvalidTransitionException.java
@@ -14,9 +14,7 @@
 
 package com.googlesource.gerrit.plugins.its.base.its;
 
-import java.io.IOException;
-
-public class InvalidTransitionException extends IOException {
+public class InvalidTransitionException extends Exception {
 
   private static final long serialVersionUID = 1L;
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfig.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfig.java
index 99fe80c..552065e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfig.java
@@ -28,9 +28,11 @@
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.ChangeRestoredEvent;
 import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.PatchSetCreatedEvent;
+import com.google.gerrit.server.events.PrivateStateChangedEvent;
+import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.events.RefUpdatedEvent;
+import com.google.gerrit.server.events.WorkInProgressStateChangedEvent;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -76,29 +78,19 @@
 
   // Plugin enablement --------------------------------------------------------
 
-  public boolean isEnabled(Event event) {
-    if (event instanceof PatchSetCreatedEvent) {
-      PatchSetCreatedEvent e = (PatchSetCreatedEvent) event;
-      return isEnabled(e.getProjectNameKey(), e.getRefName());
-    } else if (event instanceof CommentAddedEvent) {
-      CommentAddedEvent e = (CommentAddedEvent) event;
-      return isEnabled(e.getProjectNameKey(), e.getRefName());
-    } else if (event instanceof ChangeMergedEvent) {
-      ChangeMergedEvent e = (ChangeMergedEvent) event;
-      return isEnabled(e.getProjectNameKey(), e.getRefName());
-    } else if (event instanceof ChangeAbandonedEvent) {
-      ChangeAbandonedEvent e = (ChangeAbandonedEvent) event;
-      return isEnabled(e.getProjectNameKey(), e.getRefName());
-    } else if (event instanceof ChangeRestoredEvent) {
-      ChangeRestoredEvent e = (ChangeRestoredEvent) event;
-      return isEnabled(e.getProjectNameKey(), e.getRefName());
-    } else if (event instanceof RefUpdatedEvent) {
-      RefUpdatedEvent e = (RefUpdatedEvent) event;
-      return isEnabled(e.getProjectNameKey(), e.getRefName());
-    } else {
-      log.debug("Event {} not recognised and ignored", event);
-      return false;
+  public boolean isEnabled(RefEvent event) {
+    if (event instanceof PatchSetCreatedEvent
+        || event instanceof CommentAddedEvent
+        || event instanceof ChangeMergedEvent
+        || event instanceof ChangeAbandonedEvent
+        || event instanceof ChangeRestoredEvent
+        || event instanceof PrivateStateChangedEvent
+        || event instanceof WorkInProgressStateChangedEvent
+        || event instanceof RefUpdatedEvent) {
+      return isEnabled(event.getProjectNameKey(), event.getRefName());
     }
+    log.debug("Event {} not recognised and ignored", event);
+    return false;
   }
 
   public boolean isEnabled(Project.NameKey projectNK, String refName) {
@@ -110,17 +102,8 @@
           projectNK.get());
       return false;
     }
-
-    if (isEnforcedByAnyParentProject(refName, projectState)) {
-      return true;
-    }
-
-    return !"false"
-            .equals(
-                pluginCfgFactory
-                    .getFromProjectConfigWithInheritance(projectState, pluginName)
-                    .getString("enabled", "false"))
-        && isEnabledForBranch(projectState, refName);
+    return isEnforcedByAnyParentProject(refName, projectState)
+        || (isEnabledForProject(projectState) && isEnabledForBranch(projectState, refName));
   }
 
   private boolean isEnforcedByAnyParentProject(String refName, ProjectState projectState) {
@@ -134,6 +117,14 @@
     return false;
   }
 
+  private boolean isEnabledForProject(ProjectState projectState) {
+    return !"false"
+        .equals(
+            pluginCfgFactory
+                .getFromProjectConfigWithInheritance(projectState, pluginName)
+                .getString("enabled", "false"));
+  }
+
   private boolean isEnabledForBranch(ProjectState project, String refName) {
     String[] refPatterns =
         pluginCfgFactory
@@ -154,6 +145,16 @@
     return RefPatternMatcher.getMatcher(refPattern).match(refName, null);
   }
 
+  // Project association
+  public Optional<String> getItsProjectName(Project.NameKey projectNK) {
+    ProjectState projectState = projectCache.get(projectNK);
+    if (projectState == null) {
+      return Optional.empty();
+    }
+    return Optional.ofNullable(
+        pluginCfgFactory.getFromProjectConfig(projectState, pluginName).getString("its-project"));
+  }
+
   // Issue association --------------------------------------------------------
 
   /**
@@ -215,6 +216,16 @@
   }
 
   /**
+   * Pattern to skip the mandatory check for an issue. Can be used to explicitly bypass the
+   * mandatory issue pattern check for some commits.
+   *
+   * <p>When no pattern is specified, it will return a pattern which never matches.
+   */
+  public Optional<Pattern> getDummyIssuePattern() {
+    return Optional.ofNullable(getPluginConfigString("dummyIssuePattern")).map(Pattern::compile);
+  }
+
+  /**
    * Gets how necessary it is to associate commits with issues
    *
    * @return policy on how necessary association with issues is
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsFacade.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsFacade.java
index 9d7b3fa..db2857b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsFacade.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsFacade.java
@@ -31,8 +31,17 @@
 
   public void addComment(String issueId, String comment) throws IOException;
 
+  default void addValueToField(String issueId, String value, String fieldId) throws IOException {
+    throw new UnsupportedOperationException(
+        "add-value-to-field is not currently implemented by " + getClass());
+  }
+
   public void performAction(String issueId, String actionName) throws IOException;
 
+  default void createVersion(String itsProject, String version) throws IOException {
+    throw new UnsupportedOperationException("create-version is not implemented by " + getClass());
+  }
+
   public boolean exists(final String issueId) throws IOException;
 
   public String createLinkForWebui(String url, String text);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsFacadeFactory.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsFacadeFactory.java
new file mode 100644
index 0000000..4e2f035
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsFacadeFactory.java
@@ -0,0 +1,25 @@
+// 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.its.base.its;
+
+import com.google.gerrit.reviewdb.client.Project;
+
+/* An interface to get server information from child its-plugin embedded in the ItsFacade implementation */
+public interface ItsFacadeFactory {
+
+  /* Returns the object of type ItsFacade containing server info extracted from project.config if configured
+   * or the default server configured in gerrit.config if project name is empty or null*/
+  ItsFacade getFacade(Project.NameKey project);
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/NoopItsFacade.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/NoopItsFacade.java
index de4acd9..ecf3a6f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/NoopItsFacade.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/NoopItsFacade.java
@@ -32,6 +32,13 @@
   }
 
   @Override
+  public void addValueToField(String issueId, String value, String fieldId) throws IOException {
+    if (log.isDebugEnabled()) {
+      log.debug("addValueToField({},{},{})", issueId, fieldId, value);
+    }
+  }
+
+  @Override
   public void addRelatedLink(String issueId, URL relatedUrl, String description)
       throws IOException {
     if (log.isDebugEnabled()) {
@@ -55,6 +62,13 @@
   }
 
   @Override
+  public void createVersion(String itsProject, String version) {
+    if (log.isDebugEnabled()) {
+      log.debug("createVersion({},{})", itsProject, version);
+    }
+  }
+
+  @Override
   public String healthCheck(Check check) throws IOException {
     if (log.isDebugEnabled()) {
       log.debug("healthCheck()");
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/its/SingleItsServer.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/SingleItsServer.java
new file mode 100644
index 0000000..08bfe62
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/SingleItsServer.java
@@ -0,0 +1,35 @@
+// 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.its.base.its;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+
+/* An ItsServer implementation that should be bound
+ * for backward compatibility */
+public class SingleItsServer implements ItsFacadeFactory {
+
+  private final ItsFacade its;
+
+  @Inject
+  public SingleItsServer(ItsFacade its) {
+    this.its = its;
+  }
+
+  @Override
+  public ItsFacade getFacade(Project.NameKey project) {
+    return its;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/util/IssueExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/IssueExtractor.java
index 17f83c8..cb577c5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/util/IssueExtractor.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/IssueExtractor.java
@@ -1,5 +1,7 @@
 package com.googlesource.gerrit.plugins.its.base.util;
 
+import static java.util.Arrays.copyOfRange;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -17,7 +19,6 @@
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
-import org.apache.commons.lang.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -125,7 +126,26 @@
   public Map<String, Set<String>> getIssueIds(String projectName, String commitId) {
     Map<String, Set<String>> ret = Maps.newHashMap();
     String commitMessage = commitMessageFetcher.fetchGuarded(projectName, commitId);
+    addIssueIdsFromCommitMessage(ret, commitMessage);
+    return ret;
+  }
 
+  /**
+   * Gets issues from a commit message.
+   *
+   * @param commitMessage The commit message string.
+   * @return A mapping, whose keys are issue ids and whose values is a set of places where the issue
+   *     occurs. Each issue occurs at least in "somewhere". Issues from the first line get tagged
+   *     with an occurrence "subject". Issues in the last block get tagged with "footer". Issues
+   *     occurring between "subject" and "footer" get tagged with "body".
+   */
+  public Map<String, Set<String>> getIssueIdsFromCommitMessage(String commitMessage) {
+    Map<String, Set<String>> ret = Maps.newHashMap();
+    addIssueIdsFromCommitMessage(ret, commitMessage);
+    return ret;
+  }
+
+  private void addIssueIdsFromCommitMessage(Map<String, Set<String>> ret, String commitMessage) {
     addIssuesOccurrence(commitMessage, "somewhere", ret);
 
     String[] lines = commitMessage.split("\n");
@@ -157,10 +177,10 @@
         // No footer could be found. So all lines after the first one (that's
         // the subject) is the body.
         if (lines.length > 0) {
-          body = StringUtils.join(lines, "\n", 1, lines.length);
+          body = String.join("\n", copyOfRange(lines, 1, lines.length));
         }
       } else {
-        body = StringUtils.join(lines, "\n", 1, footerStart - 1);
+        body = String.join("\n", copyOfRange(lines, 1, footerStart - 1));
 
         StringBuilder footerBuilder = new StringBuilder();
         for (int lineIdx = footerStart; lineIdx < footerEnd; lineIdx++) {
@@ -180,7 +200,7 @@
           }
           footerBuilder.append(line);
         }
-        footer = StringUtils.join(lines, "\n", footerStart, footerEnd);
+        footer = String.join("\n", copyOfRange(lines, footerStart, footerEnd));
       }
       if (body != null) {
         addIssuesOccurrence(body, "body", ret);
@@ -189,7 +209,6 @@
         addIssuesOccurrence(footer, "footer", ret);
       }
     }
-    return ret;
   }
 
   /**
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/util/ItsProjectExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/ItsProjectExtractor.java
new file mode 100644
index 0000000..e872f8d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/ItsProjectExtractor.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.its.base.util;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
+import java.util.Optional;
+
+public class ItsProjectExtractor {
+
+  private final ItsConfig itsConfig;
+
+  @Inject
+  ItsProjectExtractor(ItsConfig itsConfig) {
+    this.itsConfig = itsConfig;
+  }
+
+  public Optional<String> getItsProject(String gerritProjectName) {
+    return itsConfig.getItsProjectName(new Project.NameKey(gerritProjectName));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractor.java
index 3b95144..f87f7b5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractor.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractor.java
@@ -14,7 +14,7 @@
 
 package com.googlesource.gerrit.plugins.its.base.util;
 
-import com.google.common.collect.Sets;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
@@ -22,126 +22,80 @@
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import java.util.Set;
+import java.util.HashMap;
+import java.util.Map;
 import org.apache.commons.lang.StringEscapeUtils;
 
-/** Extractor to translate the various {@code *Attribute}s to {@link Property Properties}. */
-public class PropertyAttributeExtractor {
-  private Property.Factory propertyFactory;
+/** Extractor to translate the various {@code *Attribute}s to properties. */
+class PropertyAttributeExtractor {
   private ItsFacade its;
 
   @Inject
-  PropertyAttributeExtractor(ItsFacade its, Property.Factory propertyFactory) {
+  PropertyAttributeExtractor(ItsFacade its) {
     this.its = its;
-    this.propertyFactory = propertyFactory;
   }
 
-  public Set<Property> extractFrom(AccountAttribute accountAttribute, String prefix) {
-    Set<Property> properties = Sets.newHashSet();
+  Map<String, String> extractFrom(AccountAttribute accountAttribute, String prefix) {
+    Map<String, String> properties = new HashMap<>();
     if (accountAttribute != null) {
-      // deprecated, to be removed soon. migrate to ones without dash.
-      properties.add(propertyFactory.create(prefix + "-email", accountAttribute.email));
-      properties.add(propertyFactory.create(prefix + "-username", accountAttribute.username));
-      properties.add(propertyFactory.create(prefix + "-name", accountAttribute.name));
-
-      // New style configs for vm and soy
-      properties.add(propertyFactory.create(prefix + "Email", accountAttribute.email));
-      properties.add(propertyFactory.create(prefix + "Username", accountAttribute.username));
-      properties.add(propertyFactory.create(prefix + "Name", accountAttribute.name));
+      if (accountAttribute.email != null) {
+        properties.put(prefix + "Email", accountAttribute.email);
+      }
+      if (accountAttribute.username != null) {
+        properties.put(prefix + "Username", accountAttribute.username);
+      }
+      if (accountAttribute.name != null) {
+        properties.put(prefix + "Name", accountAttribute.name);
+      }
     }
     return properties;
   }
 
-  public Set<Property> extractFrom(ChangeAttribute changeAttribute) {
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(propertyFactory.create("project", changeAttribute.project));
-    properties.add(propertyFactory.create("branch", changeAttribute.branch));
-    properties.add(propertyFactory.create("topic", changeAttribute.topic));
-    properties.add(propertyFactory.create("subject", changeAttribute.subject));
-    properties.add(
-        propertyFactory.create(
-            "escapedSubject", StringEscapeUtils.escapeJava(changeAttribute.subject)));
-
-    // deprecated, to be removed soon. migrate to ones without dash.
-    properties.add(propertyFactory.create("commit-message", changeAttribute.commitMessage));
-    properties.add(propertyFactory.create("change-id", changeAttribute.id));
-    properties.add(propertyFactory.create("change-number", String.valueOf(changeAttribute.number)));
-    properties.add(propertyFactory.create("change-url", changeAttribute.url));
-
-    // New style configs for vm and soy
-    properties.add(propertyFactory.create("commitMessage", changeAttribute.commitMessage));
-    properties.add(propertyFactory.create("changeId", changeAttribute.id));
-    properties.add(propertyFactory.create("changeNumber", String.valueOf(changeAttribute.number)));
-    properties.add(propertyFactory.create("changeUrl", changeAttribute.url));
-
-    // Soy specfic config though will work with Velocity too
-    properties.add(
-        propertyFactory.create(
-            "formatChangeUrl", its.createLinkForWebui(changeAttribute.url, changeAttribute.url)));
-
-    String status = null;
-    if (changeAttribute.status != null) {
-      status = changeAttribute.status.toString();
-    }
-    properties.add(propertyFactory.create("status", status));
-    properties.addAll(extractFrom(changeAttribute.owner, "owner"));
-    return properties;
+  Map<String, String> extractFrom(ChangeAttribute changeAttribute) {
+    return ImmutableMap.<String, String>builder()
+        .put("branch", changeAttribute.branch)
+        .put("topic", changeAttribute.topic != null ? changeAttribute.topic : "")
+        .put("subject", changeAttribute.subject)
+        .put("escapedSubject", StringEscapeUtils.escapeJava(changeAttribute.subject))
+        .put("commitMessage", changeAttribute.commitMessage)
+        .put("changeId", changeAttribute.id)
+        .put("changeNumber", String.valueOf(changeAttribute.number))
+        .put("changeUrl", changeAttribute.url)
+        .put("formatChangeUrl", its.createLinkForWebui(changeAttribute.url, changeAttribute.url))
+        .put("status", changeAttribute.status != null ? changeAttribute.status.toString() : "")
+        .put(
+            "private",
+            changeAttribute.isPrivate != null ? changeAttribute.isPrivate.toString() : "false")
+        .put("wip", changeAttribute.wip != null ? changeAttribute.wip.toString() : "false")
+        .putAll(extractFrom(changeAttribute.owner, "owner"))
+        .build();
   }
 
-  public Set<Property> extractFrom(PatchSetAttribute patchSetAttribute) {
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(propertyFactory.create("revision", patchSetAttribute.revision));
-    // deprecated, to be removed soon. migrate to ones without dash.
-    properties.add(
-        propertyFactory.create("patch-set-number", String.valueOf(patchSetAttribute.number)));
-
-    // New style configs for vm and soy
-    properties.add(
-        propertyFactory.create("patchSetNumber", String.valueOf(patchSetAttribute.number)));
-
-    properties.add(propertyFactory.create("ref", patchSetAttribute.ref));
-
-    // deprecated, to be removed soon. migrate to ones without dash.
-    properties.add(propertyFactory.create("created-on", patchSetAttribute.createdOn.toString()));
-
-    // New style configs for vm and soy
-    properties.add(propertyFactory.create("createdOn", patchSetAttribute.createdOn.toString()));
-
-    properties.add(propertyFactory.create("parents", patchSetAttribute.parents.toString()));
-    properties.add(
-        propertyFactory.create("deletions", Integer.toString(patchSetAttribute.sizeDeletions)));
-    properties.add(
-        propertyFactory.create("insertions", Integer.toString(patchSetAttribute.sizeInsertions)));
-    properties.addAll(extractFrom(patchSetAttribute.uploader, "uploader"));
-    properties.addAll(extractFrom(patchSetAttribute.author, "author"));
-    return properties;
+  Map<String, String> extractFrom(PatchSetAttribute patchSetAttribute) {
+    return ImmutableMap.<String, String>builder()
+        .put("revision", patchSetAttribute.revision)
+        .put("patchSetNumber", String.valueOf(patchSetAttribute.number))
+        .put("ref", patchSetAttribute.ref)
+        .put("createdOn", patchSetAttribute.createdOn.toString())
+        .put("parents", patchSetAttribute.parents.toString())
+        .put("deletions", Integer.toString(patchSetAttribute.sizeDeletions))
+        .put("insertions", Integer.toString(patchSetAttribute.sizeInsertions))
+        .putAll(extractFrom(patchSetAttribute.uploader, "uploader"))
+        .putAll(extractFrom(patchSetAttribute.author, "author"))
+        .build();
   }
 
-  public Set<Property> extractFrom(RefUpdateAttribute refUpdateAttribute) {
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(propertyFactory.create("revision", refUpdateAttribute.newRev));
-
-    // deprecated, to be removed soon. migrate to ones without dash.
-    properties.add(propertyFactory.create("revision-old", refUpdateAttribute.oldRev));
-
-    // New style configs for vm and soy
-    properties.add(propertyFactory.create("revisionOld", refUpdateAttribute.oldRev));
-    properties.add(propertyFactory.create("project", refUpdateAttribute.project));
-    properties.add(propertyFactory.create("ref", refUpdateAttribute.refName));
-    return properties;
+  Map<String, String> extractFrom(RefUpdateAttribute refUpdateAttribute) {
+    return ImmutableMap.<String, String>builder()
+        .put("revision", refUpdateAttribute.newRev)
+        .put("revisionOld", refUpdateAttribute.oldRev)
+        .put("ref", refUpdateAttribute.refName)
+        .build();
   }
 
-  public Set<Property> extractFrom(ApprovalAttribute approvalAttribute) {
-    Set<Property> properties = Sets.newHashSet();
-    // deprecated, to be removed soon. migrate to ones without dash.
-    properties.add(
-        propertyFactory.create("approval-" + approvalAttribute.type, approvalAttribute.value));
-
-    // New style configs for vm and soy
-    properties.add(
-        propertyFactory.create(
-            "approval" + approvalAttribute.type.replace("-", ""), approvalAttribute.value));
-    return properties;
+  public Map<String, String> extractFrom(ApprovalAttribute approvalAttribute) {
+    return ImmutableMap.<String, String>builder()
+        .put("approval" + approvalAttribute.type.replace("-", ""), approvalAttribute.value)
+        .build();
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractor.java
index f24eec4..0a1bfd1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractor.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractor.java
@@ -14,7 +14,6 @@
 
 package com.googlesource.gerrit.plugins.its.base.util;
 
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -27,31 +26,36 @@
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.ChangeRestoredEvent;
 import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.PatchSetCreatedEvent;
 import com.google.gerrit.server.events.PatchSetEvent;
+import com.google.gerrit.server.events.PrivateStateChangedEvent;
+import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.events.RefUpdatedEvent;
+import com.google.gerrit.server.events.WorkInProgressStateChangedEvent;
 import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
+import com.googlesource.gerrit.plugins.its.base.workflow.RefEventProperties;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Extractor to translate an {@link ChangeEvent} to {@link Property Properties}. */
+/** Extractor to translate an {@link ChangeEvent} to a map of properties}. */
 public class PropertyExtractor {
-  private IssueExtractor issueExtractor;
-  private Property.Factory propertyFactory;
-  private PropertyAttributeExtractor propertyAttributeExtractor;
+  private final ItsProjectExtractor itsProjectExtractor;
+  private final IssueExtractor issueExtractor;
+  private final PropertyAttributeExtractor propertyAttributeExtractor;
   private final String pluginName;
 
   @Inject
   PropertyExtractor(
       IssueExtractor issueExtractor,
-      Property.Factory propertyFactory,
+      ItsProjectExtractor itsProjectExtractor,
       PropertyAttributeExtractor propertyAttributeExtractor,
       @PluginName String pluginName) {
     this.issueExtractor = issueExtractor;
-    this.propertyFactory = propertyFactory;
+    this.itsProjectExtractor = itsProjectExtractor;
     this.propertyAttributeExtractor = propertyAttributeExtractor;
     this.pluginName = pluginName;
   }
@@ -72,40 +76,41 @@
     }
   }
 
-  private Map<String, Set<String>> extractFrom(PatchSetEvent event, Set<Property> common) {
-    common.add(propertyFactory.create("event-type", event.type));
+  private Map<String, Set<String>> extractMapFrom(PatchSetEvent event, Map<String, String> common) {
     ChangeAttribute change = event.change.get();
     PatchSetAttribute patchSet = event.patchSet.get();
-    common.addAll(propertyAttributeExtractor.extractFrom(change));
-    common.addAll(propertyAttributeExtractor.extractFrom(patchSet));
+    common.putAll(propertyAttributeExtractor.extractFrom(change));
+    common.putAll(propertyAttributeExtractor.extractFrom(patchSet));
     PatchSet.Id patchSetId =
         newPatchSetId(Integer.toString(change.number), Integer.toString(patchSet.number));
     return issueExtractor.getIssueIds(change.project, patchSet.revision, patchSetId);
   }
 
-  private Map<String, Set<String>> extractFrom(ChangeAbandonedEvent event, Set<Property> common) {
-    common.addAll(propertyAttributeExtractor.extractFrom(event.abandoner.get(), "abandoner"));
-    common.add(propertyFactory.create("reason", event.reason));
-    return extractFrom((PatchSetEvent) event, common);
+  private Map<String, Set<String>> extractFrom(
+      ChangeAbandonedEvent event, Map<String, String> common) {
+    common.putAll(propertyAttributeExtractor.extractFrom(event.abandoner.get(), "abandoner"));
+    common.put("reason", event.reason);
+    return extractMapFrom(event, common);
   }
 
-  private Map<String, Set<String>> extractFrom(ChangeMergedEvent event, Set<Property> common) {
-    common.addAll(propertyAttributeExtractor.extractFrom(event.submitter.get(), "submitter"));
-    return extractFrom((PatchSetEvent) event, common);
+  private Map<String, Set<String>> extractFrom(
+      ChangeMergedEvent event, Map<String, String> common) {
+    common.putAll(propertyAttributeExtractor.extractFrom(event.submitter.get(), "submitter"));
+    return extractMapFrom(event, common);
   }
 
-  private Map<String, Set<String>> extractFrom(ChangeRestoredEvent event, Set<Property> common) {
-    common.addAll(propertyAttributeExtractor.extractFrom(event.restorer.get(), "restorer"));
-    common.add(propertyFactory.create("reason", event.reason));
-    return extractFrom((PatchSetEvent) event, common);
+  private Map<String, Set<String>> extractFrom(
+      ChangeRestoredEvent event, Map<String, String> common) {
+    common.putAll(propertyAttributeExtractor.extractFrom(event.restorer.get(), "restorer"));
+    common.put("reason", event.reason);
+    return extractMapFrom(event, common);
   }
 
-  private Map<String, Set<String>> extractFrom(RefUpdatedEvent event, Set<Property> common) {
-    common.add(propertyFactory.create("event-type", event.type));
+  private Map<String, Set<String>> extractFrom(RefUpdatedEvent event, Map<String, String> common) {
     if (event.submitter != null) {
-      common.addAll(propertyAttributeExtractor.extractFrom(event.submitter.get(), "submitter"));
+      common.putAll(propertyAttributeExtractor.extractFrom(event.submitter.get(), "submitter"));
     }
-    common.addAll(propertyAttributeExtractor.extractFrom(event.refUpdate.get()));
+    common.putAll(propertyAttributeExtractor.extractFrom(event.refUpdate.get()));
     RefUpdateAttribute refUpdateEvent = event.refUpdate.get();
     String commitId =
         (refUpdateEvent.newRev.equals(ObjectId.zeroId().name())
@@ -114,29 +119,53 @@
     return issueExtractor.getIssueIds(event.getProjectNameKey().get(), commitId);
   }
 
-  private Map<String, Set<String>> extractFrom(PatchSetCreatedEvent event, Set<Property> common) {
-    common.addAll(propertyAttributeExtractor.extractFrom(event.uploader.get(), "uploader"));
-    return extractFrom((PatchSetEvent) event, common);
+  private Map<String, Set<String>> extractFrom(
+      PatchSetCreatedEvent event, Map<String, String> common) {
+    common.putAll(propertyAttributeExtractor.extractFrom(event.uploader.get(), "uploader"));
+    return extractMapFrom(event, common);
   }
 
-  private Map<String, Set<String>> extractFrom(CommentAddedEvent event, Set<Property> common) {
-    common.addAll(propertyAttributeExtractor.extractFrom(event.author.get(), "commenter"));
+  private Map<String, Set<String>> extractFrom(
+      CommentAddedEvent event, Map<String, String> common) {
+    common.putAll(propertyAttributeExtractor.extractFrom(event.author.get(), "commenter"));
+    common.put("comment", event.comment);
     if (event.approvals != null) {
       for (ApprovalAttribute approvalAttribute : event.approvals.get()) {
-        common.addAll(propertyAttributeExtractor.extractFrom(approvalAttribute));
+        common.putAll(propertyAttributeExtractor.extractFrom(approvalAttribute));
       }
     }
-    common.add(propertyFactory.create("comment", event.comment));
-    return extractFrom((PatchSetEvent) event, common);
+    return extractMapFrom(event, common);
+  }
+
+  private Map<String, Set<String>> extractFrom(
+      WorkInProgressStateChangedEvent event, Map<String, String> common) {
+    common.putAll(propertyAttributeExtractor.extractFrom(event.changer.get(), "changer"));
+    return extractFrom((ChangeEvent) event, common);
+  }
+
+  private Map<String, Set<String>> extractFrom(
+      PrivateStateChangedEvent event, Map<String, String> common) {
+    common.putAll(propertyAttributeExtractor.extractFrom(event.changer.get(), "changer"));
+    return extractFrom((ChangeEvent) event, common);
+  }
+
+  private Map<String, Set<String>> extractFrom(ChangeEvent event, Map<String, String> common) {
+    common.put("event-type", event.type);
+    ChangeAttribute change = event.change.get();
+    common.putAll(propertyAttributeExtractor.extractFrom(change));
+    common.put("refName", event.refName);
+
+    // Got no patch set information, extract from commit message.
+    return issueExtractor.getIssueIdsFromCommitMessage(change.commitMessage);
   }
 
   /**
-   * A set of property sets extracted from an event.
+   * A set of properties extracted from an event.
    *
-   * <p>As events may relate to more that a single issue, and properties sets are should be tied to
-   * a single issue, returning {@code Set<Property>} is not sufficient, and we need to return {@code
-   * Set<Set<Property>>}. Using this approach, a PatchSetCreatedEvent for a patch set with commit
-   * message:
+   * <p>As events may relate to more that a single issue and a group of properties should be tied to
+   * a single issue, we need to return {@code Set<Map>} of properties. As properties we understand a
+   * map of event attributes. Using this approach, a PatchSetCreatedEvent for a patch set with
+   * commit message:
    *
    * <pre>
    *   (bug 4711) Fix treatment of special characters in title
@@ -166,15 +195,22 @@
    * same event. So in the above example, a comment "mentioned in change 123" may be added for issue
    * 42, and a comment "fixed by change 123” may be added for issue 4711.
    *
-   * @param event The event to extract property sets from.
-   * @return sets of property sets extracted from the event.
+   * @param event The event to extract property maps from.
+   * @return set of property maps extracted from the event.
    */
-  public Set<Set<Property>> extractFrom(Event event) {
+  public RefEventProperties extractFrom(RefEvent event) {
     Map<String, Set<String>> associations = null;
-    Set<Set<Property>> ret = Sets.newHashSet();
+    Map<String, String> common = new HashMap<>();
+    common.put("event", event.getClass().getName());
+    String project = event.getProjectNameKey().get();
+    common.put("event-type", event.type);
+    common.put("project", project);
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertyFactory.create("event", event.getClass().getName()));
+    itsProjectExtractor
+        .getItsProject(project)
+        .ifPresent(itsProject -> common.put("its-project", itsProject));
+    common.put("ref", event.getRefName());
+    common.put("itsName", pluginName);
 
     if (event instanceof ChangeAbandonedEvent) {
       associations = extractFrom((ChangeAbandonedEvent) event, common);
@@ -188,23 +224,22 @@
       associations = extractFrom((PatchSetCreatedEvent) event, common);
     } else if (event instanceof RefUpdatedEvent) {
       associations = extractFrom((RefUpdatedEvent) event, common);
+    } else if (event instanceof PrivateStateChangedEvent) {
+      associations = extractFrom((PrivateStateChangedEvent) event, common);
+    } else if (event instanceof WorkInProgressStateChangedEvent) {
+      associations = extractFrom((WorkInProgressStateChangedEvent) event, common);
     }
 
+    Set<Map<String, String>> ret = new HashSet<>();
     if (associations != null) {
-      for (String issue : associations.keySet()) {
-        Set<Property> properties = Sets.newHashSet();
-        Property property = propertyFactory.create("issue", issue);
-        properties.add(property);
-        property = propertyFactory.create("its-name", pluginName);
-        properties.add(property);
-        for (String occurrence : associations.get(issue)) {
-          property = propertyFactory.create("association", occurrence);
-          properties.add(property);
-        }
-        properties.addAll(common);
+      for (Entry<String, Set<String>> assoc : associations.entrySet()) {
+        Map<String, String> properties = new HashMap<>();
+        properties.put("issue", assoc.getKey());
+        properties.put("association", String.join(" ", assoc.getValue()));
+        properties.putAll(common);
         ret.add(properties);
       }
     }
-    return ret;
+    return new RefEventProperties(common, ret);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateComment.java
index 969eeb0..f02050b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateComment.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateComment.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
@@ -23,6 +24,7 @@
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacadeFactory;
 import com.googlesource.gerrit.plugins.its.base.util.IssueExtractor;
 import java.io.IOException;
 import java.util.Collections;
@@ -41,9 +43,11 @@
 
   @Inject private ItsConfig itsConfig;
 
+  @Inject private ItsFacadeFactory itsFacadeFactory;
+
   @Inject private IssueExtractor issueExtractor;
 
-  private List<CommitValidationMessage> validCommit(RevCommit commit)
+  private List<CommitValidationMessage> validCommit(Project.NameKey project, RevCommit commit)
       throws CommitValidationException {
     List<CommitValidationMessage> ret = Lists.newArrayList();
     ItsAssociationPolicy associationPolicy = itsConfig.getItsAssociationPolicy();
@@ -57,6 +61,7 @@
         String details = null;
         if (issueIds.length > 0) {
           List<String> nonExistingIssueIds = Lists.newArrayList();
+          client = itsFacadeFactory.getFacade(project);
           for (String issueId : issueIds) {
             boolean exists = false;
             try {
@@ -92,7 +97,10 @@
 
             ret.add(commitValidationFailure(synopsis, details));
           }
-        } else {
+        } else if (!itsConfig
+            .getDummyIssuePattern()
+            .map(p -> p.matcher(commitMessage).find())
+            .orElse(false)) {
           synopsis = "Missing issue-id in commit message";
 
           StringBuilder sb = new StringBuilder();
@@ -132,10 +140,11 @@
   @Override
   public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
       throws CommitValidationException {
-    ItsConfig.setCurrentProjectName(receiveEvent.getProjectNameKey());
+    Project.NameKey projectName = receiveEvent.getProjectNameKey();
+    ItsConfig.setCurrentProjectName(projectName);
 
-    if (itsConfig.isEnabled(receiveEvent.getProjectNameKey(), receiveEvent.getRefName())) {
-      return validCommit(receiveEvent.commit);
+    if (itsConfig.isEnabled(projectName, receiveEvent.getRefName())) {
+      return validCommit(projectName, receiveEvent.commit);
     }
 
     return Collections.emptyList();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/Action.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Action.java
similarity index 66%
rename from src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/Action.java
rename to src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Action.java
index b8b2102..4198271 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/Action.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Action.java
@@ -12,23 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.its.base.workflow.action;
+package com.googlesource.gerrit.plugins.its.base.workflow;
 
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
 import java.io.IOException;
-import java.util.Set;
+import java.util.Map;
 
 /** Interface for actions on an issue tracking system */
 public interface Action {
+  /** @return The type of this action */
+  ActionType getType();
 
   /**
    * Execute this action.
    *
-   * @param issue The issue to execute on.
+   * @param its The facade interface to execute actions.
+   * @param target The target to execute on. Its kind will depend on the action type.
    * @param actionRequest The request to execute.
    * @param properties The properties for the execution.
    */
-  public void execute(String issue, ActionRequest actionRequest, Set<Property> properties)
+  void execute(
+      ItsFacade its, String target, ActionRequest actionRequest, Map<String, String> properties)
       throws IOException;
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionController.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionController.java
index b1c0982..4af8f1b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionController.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionController.java
@@ -14,13 +14,17 @@
 
 package com.googlesource.gerrit.plugins.its.base.workflow;
 
-import com.google.gerrit.common.EventListener;
 import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventListener;
+import com.google.gerrit.server.events.RefEvent;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
 import com.googlesource.gerrit.plugins.its.base.util.PropertyExtractor;
 import java.util.Collection;
+import java.util.Map;
 import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Controller that takes actions according to {@code ChangeEvents@}.
@@ -29,6 +33,9 @@
  * issue's status).
  */
 public class ActionController implements EventListener {
+
+  private static final Logger log = LoggerFactory.getLogger(ActionController.class);
+
   private final PropertyExtractor propertyExtractor;
   private final RuleBase ruleBase;
   private final ActionExecutor actionExecutor;
@@ -48,21 +55,48 @@
 
   @Override
   public void onEvent(Event event) {
-    if (!itsConfig.isEnabled(event)) {
+    if (event instanceof RefEvent) {
+      RefEvent refEvent = (RefEvent) event;
+      if (itsConfig.isEnabled(refEvent)) {
+        handleEvent(refEvent);
+      }
+    }
+  }
+
+  private void handleEvent(RefEvent refEvent) {
+    RefEventProperties refEventProperties = propertyExtractor.extractFrom(refEvent);
+
+    handleIssuesEvent(refEventProperties.getIssuesProperties());
+    handleProjectEvent(refEventProperties.getProjectProperties());
+  }
+
+  private void handleIssuesEvent(Set<Map<String, String>> issuesProperties) {
+    for (Map<String, String> issueProperties : issuesProperties) {
+      Collection<ActionRequest> actions = ruleBase.actionRequestsFor(issueProperties);
+      if (!actions.isEmpty()) {
+        actionExecutor.executeOnIssue(actions, issueProperties);
+      }
+    }
+  }
+
+  private void handleProjectEvent(Map<String, String> projectProperties) {
+    if (projectProperties.isEmpty()) {
       return;
     }
 
-    Set<Set<Property>> propertiesCollections = propertyExtractor.extractFrom(event);
-    for (Set<Property> properties : propertiesCollections) {
-      Collection<ActionRequest> actions = ruleBase.actionRequestsFor(properties);
-      if (!actions.isEmpty()) {
-        for (Property property : properties) {
-          if ("issue".equals(property.getKey())) {
-            String issue = property.getValue();
-            actionExecutor.execute(issue, actions, properties);
-          }
-        }
-      }
+    Collection<ActionRequest> projectActions = ruleBase.actionRequestsFor(projectProperties);
+    if (projectActions.isEmpty()) {
+      return;
     }
+    if (!projectProperties.containsKey("its-project")) {
+      String project = projectProperties.get("project");
+      log.error(
+          "Could not process project event. No its-project associated with project {}. "
+              + "Did you forget to configure the ITS project association in project.config?",
+          project);
+      return;
+    }
+
+    actionExecutor.executeOnProject(projectActions, projectProperties);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutor.java
index 0e31b93..355d1f3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutor.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutor.java
@@ -14,16 +14,14 @@
 
 package com.googlesource.gerrit.plugins.its.base.workflow;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.PluginName;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.Action;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddSoyComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddStandardComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddVelocityComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.LogEvent;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacadeFactory;
 import java.io.IOException;
-import java.util.Set;
+import java.util.Map;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -31,58 +29,103 @@
 public class ActionExecutor {
   private static final Logger log = LoggerFactory.getLogger(ActionExecutor.class);
 
-  private final ItsFacade its;
+  private final ItsFacadeFactory itsFactory;
   private final AddComment.Factory addCommentFactory;
   private final AddStandardComment.Factory addStandardCommentFactory;
-  private final AddVelocityComment.Factory addVelocityCommentFactory;
   private final AddSoyComment.Factory addSoyCommentFactory;
   private final LogEvent.Factory logEventFactory;
+  private final AddPropertyToField.Factory addPropertyToFieldFactory;
+  private final CreateVersionFromProperty.Factory createVersionFromPropertyFactory;
+  private final DynamicMap<CustomAction> customActions;
 
   @Inject
   public ActionExecutor(
-      ItsFacade its,
+      ItsFacadeFactory itsFactory,
       AddComment.Factory addCommentFactory,
       AddStandardComment.Factory addStandardCommentFactory,
-      AddVelocityComment.Factory addVelocityCommentFactory,
       AddSoyComment.Factory addSoyCommentFactory,
-      LogEvent.Factory logEventFactory) {
-    this.its = its;
+      LogEvent.Factory logEventFactory,
+      AddPropertyToField.Factory addPropertyToFieldFactory,
+      CreateVersionFromProperty.Factory createVersionFromPropertyFactory,
+      DynamicMap<CustomAction> customActions) {
+    this.itsFactory = itsFactory;
     this.addCommentFactory = addCommentFactory;
     this.addStandardCommentFactory = addStandardCommentFactory;
-    this.addVelocityCommentFactory = addVelocityCommentFactory;
     this.addSoyCommentFactory = addSoyCommentFactory;
     this.logEventFactory = logEventFactory;
+    this.addPropertyToFieldFactory = addPropertyToFieldFactory;
+    this.createVersionFromPropertyFactory = createVersionFromPropertyFactory;
+    this.customActions = customActions;
   }
 
-  public void execute(String issue, ActionRequest actionRequest, Set<Property> properties) {
-    try {
-      String name = actionRequest.getName();
-      Action action = null;
-      if ("add-comment".equals(name)) {
-        action = addCommentFactory.create();
-      } else if ("add-standard-comment".equals(name)) {
-        action = addStandardCommentFactory.create();
-      } else if ("add-velocity-comment".equals(name)) {
-        action = addVelocityCommentFactory.create();
-      } else if ("add-soy-comment".equals(name)) {
-        action = addSoyCommentFactory.create();
-      } else if ("log-event".equals(name)) {
-        action = logEventFactory.create();
-      }
+  private Action getAction(String actionName) {
+    switch (actionName) {
+      case "add-comment":
+        return addCommentFactory.create();
+      case "add-standard-comment":
+        return addStandardCommentFactory.create();
+      case "add-soy-comment":
+        return addSoyCommentFactory.create();
+      case "log-event":
+        return logEventFactory.create();
+      case "add-property-to-field":
+        return addPropertyToFieldFactory.create();
+      case "create-version-from-property":
+        return createVersionFromPropertyFactory.create();
+      default:
+        return customActions.get(PluginName.GERRIT, actionName);
+    }
+  }
 
+  private void execute(
+      Action action, String target, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    ItsFacade its = itsFactory.getFacade(new Project.NameKey(properties.get("project")));
+    action.execute(its, target, actionRequest, properties);
+  }
+
+  private void executeOnIssue(
+      String issue, ActionRequest actionRequest, Map<String, String> properties) {
+    try {
+      Action action = getAction(actionRequest.getName());
       if (action == null) {
+        ItsFacade its = itsFactory.getFacade(new Project.NameKey(properties.get("project")));
         its.performAction(issue, actionRequest.getUnparsed());
-      } else {
-        action.execute(issue, actionRequest, properties);
+      } else if (action.getType() == ActionType.ISSUE) {
+        execute(action, issue, actionRequest, properties);
       }
     } catch (IOException e) {
       log.error("Error while executing action " + actionRequest, e);
     }
   }
 
-  public void execute(String issue, Iterable<ActionRequest> actions, Set<Property> properties) {
+  public void executeOnIssue(Iterable<ActionRequest> actions, Map<String, String> properties) {
     for (ActionRequest actionRequest : actions) {
-      execute(issue, actionRequest, properties);
+      executeOnIssue(properties.get("issue"), actionRequest, properties);
+    }
+  }
+
+  private void executeOnProject(
+      String itsProject, ActionRequest actionRequest, Map<String, String> properties) {
+    try {
+      String actionName = actionRequest.getName();
+      Action action = getAction(actionName);
+      if (action == null) {
+        log.debug("No action found for name {}", actionName);
+        return;
+      }
+      if (action.getType() != ActionType.PROJECT) {
+        return;
+      }
+      execute(action, itsProject, actionRequest, properties);
+    } catch (IOException e) {
+      log.error("Error while executing action " + actionRequest, e);
+    }
+  }
+
+  public void executeOnProject(Iterable<ActionRequest> actions, Map<String, String> properties) {
+    for (ActionRequest actionRequest : actions) {
+      executeOnProject(properties.get("its-project"), actionRequest, properties);
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionRequest.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionRequest.java
index dec671e..964c01d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionRequest.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionRequest.java
@@ -49,11 +49,7 @@
    * @return The name of the requested action, if a name has been given. "" otherwise.
    */
   public String getName() {
-    String ret = "";
-    if (chopped.length > 0) {
-      ret = chopped[0];
-    }
-    return ret;
+    return getParameter(0);
   }
 
   /**
@@ -63,11 +59,7 @@
    * @return The name of the requested parameter, if the requested parameter exists. "" otherwise.
    */
   public String getParameter(int i) {
-    String ret = "";
-    if (chopped.length > i) {
-      ret = chopped[i];
-    }
-    return ret;
+    return chopped.length > i ? chopped[i] : "";
   }
 
   /**
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionType.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionType.java
new file mode 100644
index 0000000..8d18cce
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionType.java
@@ -0,0 +1,22 @@
+// 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.its.base.workflow;
+
+public enum ActionType {
+  /** Actions that will be executed on ITS issues */
+  ISSUE,
+  /** Actions that will be executed on ITS projects */
+  PROJECT
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddComment.java
similarity index 61%
rename from src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddComment.java
rename to src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddComment.java
index 16855d3..2624613 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddComment.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddComment.java
@@ -12,39 +12,28 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.its.base.workflow.action;
+package com.googlesource.gerrit.plugins.its.base.workflow;
 
 import com.google.common.base.Strings;
-import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
 import java.io.IOException;
-import java.util.Set;
-import org.apache.commons.lang.StringUtils;
+import java.util.Map;
 
 /**
  * Adds a fixed comment to an issue.
  *
  * <p>The action requests parameters get concatenated and get added to the issue.
  */
-public class AddComment implements Action {
+public class AddComment extends IssueAction {
   public interface Factory {
     AddComment create();
   }
 
-  private final ItsFacade its;
-
-  @Inject
-  public AddComment(ItsFacade its) {
-    this.its = its;
-  }
-
   @Override
-  public void execute(String issue, ActionRequest actionRequest, Set<Property> properties)
+  public void execute(
+      ItsFacade its, String issue, ActionRequest actionRequest, Map<String, String> properties)
       throws IOException {
-    String[] parameters = actionRequest.getParameters();
-    String comment = StringUtils.join(parameters, " ");
+    String comment = String.join(" ", actionRequest.getParameters());
     if (!Strings.isNullOrEmpty(comment)) {
       its.addComment(issue, comment);
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToField.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToField.java
new file mode 100644
index 0000000..c19aa73
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToField.java
@@ -0,0 +1,47 @@
+// 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.its.base.workflow;
+
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+
+public class AddPropertyToField extends IssueAction {
+
+  public interface Factory {
+    AddPropertyToField create();
+  }
+
+  private final AddPropertyToFieldParametersExtractor parametersExtractor;
+
+  @Inject
+  public AddPropertyToField(AddPropertyToFieldParametersExtractor parametersExtractor) {
+    this.parametersExtractor = parametersExtractor;
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String issue, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    Optional<AddPropertyToFieldParameters> parameters =
+        parametersExtractor.extract(actionRequest, properties);
+    if (!parameters.isPresent()) {
+      return;
+    }
+    its.addValueToField(issue, parameters.get().getPropertyValue(), parameters.get().getFieldId());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParameters.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParameters.java
new file mode 100644
index 0000000..55b029d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParameters.java
@@ -0,0 +1,37 @@
+// 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.its.base.workflow;
+
+/** Parameters needed by {@link AddPropertyToField} action */
+public class AddPropertyToFieldParameters {
+
+  private final String propertyValue;
+  private final String fieldId;
+
+  public AddPropertyToFieldParameters(String propertyValue, String fieldId) {
+    this.propertyValue = propertyValue;
+    this.fieldId = fieldId;
+  }
+
+  /** @return The event property's value to add to the ITS field */
+  public String getPropertyValue() {
+    return propertyValue;
+  }
+
+  /** @return The id of the ITS field to which the property value is added */
+  public String getFieldId() {
+    return fieldId;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParametersExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParametersExtractor.java
new file mode 100644
index 0000000..7a259f1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParametersExtractor.java
@@ -0,0 +1,67 @@
+// 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.its.base.workflow;
+
+import com.google.common.base.Strings;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class AddPropertyToFieldParametersExtractor {
+
+  private static final Logger log =
+      LoggerFactory.getLogger(AddPropertyToFieldParametersExtractor.class);
+
+  @Inject
+  public AddPropertyToFieldParametersExtractor() {}
+
+  /**
+   * @return The parameters needed to perform an AddPropertyToField action. Empty if the parameters
+   *     could not be extracted.
+   */
+  public Optional<AddPropertyToFieldParameters> extract(
+      ActionRequest actionRequest, Map<String, String> properties) {
+    String[] parameters = actionRequest.getParameters();
+    if (parameters.length != 2) {
+      log.error(
+          "Wrong number of received parameters. Received parameters are {}. Exactly two parameters are expected. The first one is the ITS field id, the second one is the event property id",
+          Arrays.toString(parameters));
+      return Optional.empty();
+    }
+
+    String propertyId = parameters[0];
+    if (Strings.isNullOrEmpty(propertyId)) {
+      log.error("Received property id is blank");
+      return Optional.empty();
+    }
+
+    String fieldId = parameters[1];
+    if (Strings.isNullOrEmpty(fieldId)) {
+      log.error("Received field id is blank");
+      return Optional.empty();
+    }
+
+    if (!properties.containsKey(propertyId)) {
+      log.error("No event property found for id {}", propertyId);
+      return Optional.empty();
+    }
+
+    String propertyValue = properties.get(propertyId);
+    return Optional.of(new AddPropertyToFieldParameters(propertyValue, fieldId));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddSoyComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddSoyComment.java
new file mode 100644
index 0000000..c29d622
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddSoyComment.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.its.base.workflow;
+
+import com.google.common.base.Strings;
+import com.google.common.io.CharStreams;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.SoyFileSet.Builder;
+import com.google.template.soy.data.SanitizedContent;
+import com.google.template.soy.tofu.SoyTofu;
+import com.googlesource.gerrit.plugins.its.base.ItsPath;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Adds a short predefined comments to an issue.
+ *
+ * <p>Comments are added for merging, abandoning, restoring of changes and adding of patch sets.
+ */
+public class AddSoyComment extends IssueAction {
+  private static final Logger log = LoggerFactory.getLogger(AddSoyComment.class);
+
+  public interface Factory {
+    AddSoyComment create();
+  }
+
+  private final Path templateDir;
+  protected HashMap<String, Object> soyContext;
+
+  @Inject
+  public AddSoyComment(@ItsPath Path itsPath) {
+    this.templateDir = itsPath.resolve("templates");
+  }
+
+  private String soyTemplate(
+      SoyFileSet.Builder builder,
+      String template,
+      SanitizedContent.ContentKind kind,
+      Map<String, String> properties) {
+    Path templatePath = templateDir.resolve(template + ".soy");
+    String content;
+
+    try (Reader r = Files.newBufferedReader(templatePath, StandardCharsets.UTF_8)) {
+      content = CharStreams.toString(r);
+    } catch (IOException err) {
+      throw new ProvisionException(
+          "Failed to read template file " + templatePath.toAbsolutePath().toString(), err);
+    }
+
+    builder.add(content, templatePath.toAbsolutePath().toString());
+    SoyTofu.Renderer renderer =
+        builder
+            .build()
+            .compileToTofu()
+            .newRenderer("etc.its.templates." + template)
+            .setContentKind(kind)
+            .setData(properties);
+    return renderer.render();
+  }
+
+  private String soyTextTemplate(Builder builder, String template, Map<String, String> properties) {
+    return soyTemplate(builder, template, SanitizedContent.ContentKind.TEXT, properties);
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String issue, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    String comment = buildComment(actionRequest, properties);
+    if (!Strings.isNullOrEmpty(comment)) {
+      its.addComment(issue, comment);
+    }
+  }
+
+  private String buildComment(ActionRequest actionRequest, Map<String, String> properties) {
+    String template = actionRequest.getParameter(1);
+    if (!template.isEmpty()) {
+      return soyTextTemplate(SoyFileSet.builder(), template, properties);
+    }
+    log.error("No template name given in {}", actionRequest);
+    return "";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddStandardComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddStandardComment.java
new file mode 100644
index 0000000..d289024
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/AddStandardComment.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.its.base.workflow;
+
+import com.google.common.base.Strings;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Adds a short predefined comments to an issue.
+ *
+ * <p>Comments are added for merging, abandoning, restoring of changes and adding of patch sets.
+ */
+public class AddStandardComment extends IssueAction {
+  public interface Factory {
+    AddStandardComment create();
+  }
+
+  private String getCommentChangeEvent(String action, String prefix, Map<String, String> map) {
+    String ret = "";
+    String changeNumber = Strings.nullToEmpty(map.get("changeNumber"));
+    if (!changeNumber.isEmpty()) {
+      changeNumber += " ";
+    }
+    ret += "Change " + changeNumber + action;
+    String submitter = getValueFromMap(map, prefix, "Name", "Username");
+    if (!submitter.isEmpty()) {
+      ret += " by " + submitter;
+    }
+    String subject = Strings.nullToEmpty(map.get("subject"));
+    if (!subject.isEmpty()) {
+      ret += ":\n" + subject;
+    }
+    String reason = Strings.nullToEmpty(map.get("reason"));
+    if (!reason.isEmpty()) {
+      ret += "\n\nReason:\n" + reason;
+    }
+    String url = Strings.nullToEmpty(map.get("formatChangeUrl"));
+    if (!url.isEmpty()) {
+      ret += "\n\n" + url;
+    }
+    return ret;
+  }
+
+  private String getValueFromMap(Map<String, String> map, String keyPrefix, String... keyOptions) {
+    for (String key : keyOptions) {
+      String ret = Strings.nullToEmpty(map.get(keyPrefix + key));
+      if (!ret.isEmpty()) {
+        return ret;
+      }
+    }
+    return "";
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String issue, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    String comment = buildComment(properties);
+    if (!Strings.isNullOrEmpty(comment)) {
+      its.addComment(issue, comment);
+    }
+  }
+
+  private String buildComment(Map<String, String> properties) {
+    switch (properties.get("event-type")) {
+      case "change-abandoned":
+        return getCommentChangeEvent("abandoned", "abandoner", properties);
+      case "change-merged":
+        return getCommentChangeEvent("merged", "submitter", properties);
+      case "change-restored":
+        return getCommentChangeEvent("restored", "restorer", properties);
+      case "patchset-created":
+        return getCommentChangeEvent("had a related patch set uploaded", "uploader", properties);
+      default:
+        return "";
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Condition.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Condition.java
index 6f8df84..e527652 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Condition.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Condition.java
@@ -20,9 +20,9 @@
 import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.Action;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -87,13 +87,12 @@
    *     conditions, true iff properties does not contain any property that matches the rules key
    *     and whose value matches at least one of the rule's value.
    */
-  public boolean isMetBy(Iterable<Property> properties) {
-    for (Property property : properties) {
-      String propertyKey = property.getKey();
-      if ((key == null && propertyKey == null) || (key != null && key.equals(propertyKey))) {
-        if (values.contains(property.getValue())) {
-          return !negated;
-        }
+  public boolean isMetBy(Map<String, String> properties) {
+    String property = properties.get(key);
+    String[] propertyValues = property != null ? property.split(" ") : new String[] {};
+    for (String p : propertyValues) {
+      if (values.contains(p.trim())) {
+        return !negated;
       }
     }
     return negated;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromProperty.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromProperty.java
new file mode 100644
index 0000000..91cd34d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromProperty.java
@@ -0,0 +1,50 @@
+// 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.its.base.workflow;
+
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+
+/** Creates a version in the ITS. The value of the version is extracted from an event property. */
+public class CreateVersionFromProperty extends ProjectAction {
+
+  public interface Factory {
+    CreateVersionFromProperty create();
+  }
+
+  private final CreateVersionFromPropertyParametersExtractor parametersExtractor;
+
+  @Inject
+  public CreateVersionFromProperty(
+      CreateVersionFromPropertyParametersExtractor parametersExtractor) {
+    this.parametersExtractor = parametersExtractor;
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String itsProject, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    Optional<CreateVersionFromPropertyParameters> parameters =
+        parametersExtractor.extract(actionRequest, properties);
+    if (!parameters.isPresent()) {
+      return;
+    }
+
+    its.createVersion(itsProject, parameters.get().getPropertyValue());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParameters.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParameters.java
new file mode 100644
index 0000000..2ddc245
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParameters.java
@@ -0,0 +1,30 @@
+// 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.its.base.workflow;
+
+/** Parameters needed by {@link CreateVersionFromProperty} action */
+public class CreateVersionFromPropertyParameters {
+
+  private final String propertyValue;
+
+  public CreateVersionFromPropertyParameters(String propertyValue) {
+    this.propertyValue = propertyValue;
+  }
+
+  /** @return The extracted property value that will be used as the version value */
+  public String getPropertyValue() {
+    return propertyValue;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParametersExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParametersExtractor.java
new file mode 100644
index 0000000..1eb01ce
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParametersExtractor.java
@@ -0,0 +1,57 @@
+// 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.its.base.workflow;
+
+import com.google.common.base.Strings;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class CreateVersionFromPropertyParametersExtractor {
+
+  private static final Logger log =
+      LoggerFactory.getLogger(CreateVersionFromPropertyParametersExtractor.class);
+
+  @Inject
+  public CreateVersionFromPropertyParametersExtractor() {}
+
+  public Optional<CreateVersionFromPropertyParameters> extract(
+      ActionRequest actionRequest, Map<String, String> properties) {
+    String[] parameters = actionRequest.getParameters();
+    if (parameters.length != 1) {
+      log.error(
+          "Wrong number of received parameters. Received parameters are {}. Only one parameter is expected, the property id.",
+          Arrays.toString(parameters));
+      return Optional.empty();
+    }
+
+    String propertyId = parameters[0];
+    if (Strings.isNullOrEmpty(propertyId)) {
+      log.error("Received property id is blank");
+      return Optional.empty();
+    }
+
+    if (!properties.containsKey(propertyId)) {
+      log.error("No event property found for id {}", propertyId);
+      return Optional.empty();
+    }
+
+    String propertyValue = properties.get(propertyId);
+    return Optional.of(new CreateVersionFromPropertyParameters(propertyValue));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CustomAction.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CustomAction.java
new file mode 100644
index 0000000..5a79650
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/CustomAction.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.its.base.workflow;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Interface for actions specific to its-* plugins * */
+@ExtensionPoint
+public interface CustomAction extends Action {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/IssueAction.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/IssueAction.java
new file mode 100644
index 0000000..37707ad
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/IssueAction.java
@@ -0,0 +1,24 @@
+// 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.its.base.workflow;
+
+/** Abstraction for actions on ITS issues */
+public abstract class IssueAction implements StandardAction {
+
+  @Override
+  public final ActionType getType() {
+    return ActionType.ISSUE;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCache.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCache.java
new file mode 100644
index 0000000..265032b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCache.java
@@ -0,0 +1,22 @@
+package com.googlesource.gerrit.plugins.its.base.workflow;
+
+import java.util.List;
+
+/** Cache of project defined ITS rules */
+public interface ItsRulesProjectCache {
+
+  /**
+   * Get the cached ITS rules for a project
+   *
+   * @param projectName name of the project.
+   * @return the cached rules; an empty list if no such project exists or projectName is null.
+   */
+  List<Rule> get(String projectName);
+
+  /**
+   * Invalidate the cached rules for the given project.
+   *
+   * @param projectName project for which the rules are being evicted
+   */
+  void evict(String projectName);
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheImpl.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheImpl.java
new file mode 100644
index 0000000..7e37397
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheImpl.java
@@ -0,0 +1,123 @@
+// 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.its.base.workflow;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import com.googlesource.gerrit.plugins.its.base.GlobalRulesFileName;
+import com.googlesource.gerrit.plugins.its.base.PluginRulesFileName;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ItsRulesProjectCacheImpl implements ItsRulesProjectCache {
+  private static final Logger log = LoggerFactory.getLogger(ItsRulesProjectCacheImpl.class);
+  private static final String CACHE_NAME = "its_rules_project";
+
+  private final LoadingCache<String, List<Rule>> cache;
+
+  @Inject
+  ItsRulesProjectCacheImpl(@Named(CACHE_NAME) LoadingCache<String, List<Rule>> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public List<Rule> get(String projectName) {
+    try {
+      return cache.get(projectName);
+    } catch (ExecutionException e) {
+      log.warn("Cannot get project specific rules for project {}", projectName, e);
+      return ImmutableList.of();
+    }
+  }
+
+  @Override
+  public void evict(String projectName) {
+    cache.invalidate(projectName);
+  }
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(CACHE_NAME, String.class, new TypeLiteral<List<Rule>>() {}).loader(Loader.class);
+
+        bind(ItsRulesProjectCacheImpl.class);
+        bind(ItsRulesProjectCache.class).to(ItsRulesProjectCacheImpl.class);
+        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+            .to(ItsRulesProjectCacheRefresher.class);
+      }
+    };
+  }
+
+  static class Loader extends CacheLoader<String, List<Rule>> {
+    private final String globalRulesFileName;
+    private final String pluginRulesFileName;
+    private final ProjectCache projectCache;
+    private final RulesConfigReader rulesConfigReader;
+
+    @Inject
+    Loader(
+        @GlobalRulesFileName String globalRulesFileName,
+        @PluginRulesFileName String pluginRulesFileName,
+        ProjectCache projectCache,
+        RulesConfigReader rulesConfigReader) {
+      this.globalRulesFileName = globalRulesFileName;
+      this.pluginRulesFileName = pluginRulesFileName;
+      this.projectCache = projectCache;
+      this.rulesConfigReader = rulesConfigReader;
+    }
+
+    @Override
+    public List<Rule> load(String projectName) throws IOException {
+      ProjectState project = projectCache.checkedGet(new Project.NameKey(projectName));
+      List<Rule> projectRules = readRulesFrom(project);
+      if (projectRules.isEmpty()) {
+        for (ProjectState parent : project.parents()) {
+          projectRules = readRulesFrom(parent);
+          if (!projectRules.isEmpty()) {
+            break;
+          }
+        }
+      }
+      return projectRules;
+    }
+
+    private List<Rule> readRulesFrom(ProjectState project) {
+      Config general = project.getConfig(globalRulesFileName).get();
+      Config pluginSpecific = project.getConfig(pluginRulesFileName).get();
+      return new ImmutableList.Builder<Rule>()
+          .addAll(rulesConfigReader.getRulesFromConfig(general))
+          .addAll(rulesConfigReader.getRulesFromConfig(pluginSpecific))
+          .build();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheRefresher.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheRefresher.java
new file mode 100644
index 0000000..4cbd3d2
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheRefresher.java
@@ -0,0 +1,39 @@
+package com.googlesource.gerrit.plugins.its.base.workflow;
+
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ItsRulesProjectCacheRefresher implements GitReferenceUpdatedListener {
+  private static final Logger log = LoggerFactory.getLogger(ItsRulesProjectCacheRefresher.class);
+
+  private final GerritApi gApi;
+  private final ItsRulesProjectCache itsRuleProjectCache;
+
+  @Inject
+  ItsRulesProjectCacheRefresher(GerritApi gApi, ItsRulesProjectCache itsRuleProjectCache) {
+    this.gApi = gApi;
+    this.itsRuleProjectCache = itsRuleProjectCache;
+  }
+
+  @Override
+  public void onGitReferenceUpdated(Event event) {
+    if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
+      return;
+    }
+    String projectName = event.getProjectName();
+    itsRuleProjectCache.evict(projectName);
+    try {
+      for (ProjectInfo childProject : gApi.projects().name(projectName).children()) {
+        itsRuleProjectCache.evict(childProject.name);
+      }
+    } catch (RestApiException e) {
+      log.warn("Unable to evict ITS rules cache", e);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/LogEvent.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/LogEvent.java
similarity index 60%
rename from src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/LogEvent.java
rename to src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/LogEvent.java
index 3a46202..6eec9a4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/LogEvent.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/LogEvent.java
@@ -12,13 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.googlesource.gerrit.plugins.its.base.workflow.action;
+package com.googlesource.gerrit.plugins.its.base.workflow;
 
 import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
 import java.io.IOException;
-import java.util.Set;
+import java.util.Map;
+import java.util.Map.Entry;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -27,14 +27,25 @@
  *
  * <p>This event helps when developing rules as available properties become visible.
  */
-public class LogEvent implements Action {
+public class LogEvent extends IssueAction {
   private static final Logger log = LoggerFactory.getLogger(LogEvent.class);
 
   private enum Level {
     ERROR,
     WARN,
     INFO,
-    DEBUG
+    DEBUG;
+
+    static Level fromString(String s) {
+      if (s != null) {
+        for (Level level : Level.values()) {
+          if (s.toUpperCase().equals(level.toString())) {
+            return level;
+          }
+        }
+      }
+      return INFO;
+    }
   }
 
   public interface Factory {
@@ -44,8 +55,8 @@
   @Inject
   public LogEvent() {}
 
-  private void logProperty(Level level, Property property) {
-    String message = property.toString();
+  private void logProperty(Level level, Entry<String, String> property) {
+    String message = String.format("[%s = %s]", property.getKey(), property.getValue());
     switch (level) {
       case ERROR:
         log.error(message);
@@ -65,24 +76,11 @@
   }
 
   @Override
-  public void execute(String issue, ActionRequest actionRequest, Set<Property> properties)
+  public void execute(
+      ItsFacade its, String issue, ActionRequest actionRequest, Map<String, String> properties)
       throws IOException {
-    String levelParameter = actionRequest.getParameter(1);
-    if (levelParameter != null) {
-      levelParameter = levelParameter.toLowerCase();
-    }
-    Level level = Level.INFO;
-    if ("error".equals(levelParameter)) {
-      level = Level.ERROR;
-    } else if ("warn".equals(levelParameter)) {
-      level = Level.WARN;
-    } else if ("info".equals(levelParameter)) {
-      level = Level.INFO;
-    } else if ("debug".equals(levelParameter)) {
-      level = Level.DEBUG;
-    }
-
-    for (Property property : properties) {
+    Level level = Level.fromString(actionRequest.getParameter(1));
+    for (Entry<String, String> property : properties.entrySet()) {
       logProperty(level, property);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ProjectAction.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ProjectAction.java
new file mode 100644
index 0000000..5867377
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ProjectAction.java
@@ -0,0 +1,24 @@
+// 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.its.base.workflow;
+
+/** Abstraction for actions on ITS projects */
+public abstract class ProjectAction implements StandardAction {
+
+  @Override
+  public final ActionType getType() {
+    return ActionType.PROJECT;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Property.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Property.java
deleted file mode 100644
index eb1f087..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Property.java
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright (C) 2013 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.its.base.workflow;
-
-import com.google.gerrit.common.Nullable;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/**
- * A property to match against {@code Condition}s.
- *
- * <p>A property is a simple key value pair.
- */
-public class Property {
-  public interface Factory {
-    Property create(@Assisted("key") String key, @Assisted("value") String value);
-  }
-
-  private final String key;
-  private final String value;
-
-  @Inject
-  public Property(@Assisted("key") String key, @Nullable @Assisted("value") String value) {
-    this.key = key;
-    this.value = value;
-  }
-
-  public String getKey() {
-    return key;
-  }
-
-  public String getValue() {
-    return value;
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    boolean ret = false;
-    if (other != null && other instanceof Property) {
-      Property otherProperty = (Property) other;
-      ret = true;
-
-      if (key == null) {
-        ret &= otherProperty.getKey() == null;
-      } else {
-        ret &= key.equals(otherProperty.getKey());
-      }
-
-      if (value == null) {
-        ret &= otherProperty.getValue() == null;
-      } else {
-        ret &= value.equals(otherProperty.getValue());
-      }
-    }
-    return ret;
-  }
-
-  @Override
-  public int hashCode() {
-    return (key == null ? 0 : key.hashCode()) * 31 + (value == null ? 0 : value.hashCode());
-  }
-
-  @Override
-  public String toString() {
-    return "[" + key + " = " + value + "]";
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RefEventProperties.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RefEventProperties.java
new file mode 100644
index 0000000..30940bb
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RefEventProperties.java
@@ -0,0 +1,50 @@
+// 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.its.base.workflow;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+/** The properties extracted from a {@link com.google.gerrit.server.events.RefEvent} */
+public class RefEventProperties {
+
+  private final Map<String, String> projectProperties;
+  private final Set<Map<String, String>> issuesProperties;
+
+  /**
+   * @param projectProperties Properties of the ref event
+   * @param issuesProperties Properties of the ref event added of the properties specific to the
+   *     issues. There will be as many set of properties as number of issues
+   */
+  public RefEventProperties(
+      Map<String, String> projectProperties, Set<Map<String, String>> issuesProperties) {
+    this.projectProperties = Collections.unmodifiableMap(projectProperties);
+    this.issuesProperties = Collections.unmodifiableSet(issuesProperties);
+  }
+
+  /** @return Properties of the ref event */
+  public Map<String, String> getProjectProperties() {
+    return projectProperties;
+  }
+
+  /**
+   * @return Properties of the ref event added of the properties specific to the issues. There will
+   *     be as many set of properties as number of issues
+   */
+  public Set<Map<String, String>> getIssuesProperties() {
+    return issuesProperties;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Rule.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Rule.java
index 3efa269..b83b969 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Rule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/Rule.java
@@ -21,6 +21,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /** A single rule that associates {@code Action}s to {@code Condition}s. */
@@ -71,7 +72,7 @@
    * @param properties The properties to check against the rule's conditions.
    * @return The actions that should get fired.
    */
-  public Collection<ActionRequest> actionRequestsFor(Iterable<Property> properties) {
+  public Collection<ActionRequest> actionRequestsFor(Map<String, String> properties) {
     for (Condition condition : conditions) {
       if (!condition.isMetBy(properties)) {
         return Collections.emptyList();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBase.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBase.java
index 41dc9fa..b6e4ad6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBase.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBase.java
@@ -14,42 +14,31 @@
 
 package com.googlesource.gerrit.plugins.its.base.workflow;
 
-import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.gerrit.server.config.SitePath;
+import com.google.common.collect.ImmutableList;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.its.base.GlobalRulesFileName;
+import com.googlesource.gerrit.plugins.its.base.ItsPath;
+import com.googlesource.gerrit.plugins.its.base.PluginRulesFileName;
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-/** Collection and matcher agains {@link Rule}s. */
+/** Collection and matcher against {@link Rule}s. */
 public class RuleBase {
   private static final Logger log = LoggerFactory.getLogger(RuleBase.class);
 
-  /** File beginning (relative to site) to load rules from */
-  private static final String ITS_CONFIG_FILE_START =
-      "etc" + File.separatorChar + "its" + File.separator + "actions";
-
-  /** File end to load rules from */
-  private static final String ITS_CONFIG_FILE_END = ".config";
-
-  /** The section for rules within rulebases */
-  private static final String RULE_SECTION = "rule";
-
-  /** The key for actions within rulebases */
-  private static final String ACTION_KEY = "action";
-
-  private final Path sitePath;
-  private final Rule.Factory ruleFactory;
-  private final Condition.Factory conditionFactory;
-  private final ActionRequest.Factory actionRequestFactory;
-  private final String pluginName;
+  private final File globalRuleFile;
+  private final File itsSpecificRuleFile;
+  private final ItsRulesProjectCache rulesProjectCache;
 
   private Collection<Rule> rules;
 
@@ -59,107 +48,43 @@
 
   @Inject
   public RuleBase(
-      @SitePath Path sitePath,
-      Rule.Factory ruleFactory,
-      Condition.Factory conditionFactory,
-      ActionRequest.Factory actionRequestFactory,
-      @PluginName String pluginName) {
-    this.sitePath = sitePath;
-    this.ruleFactory = ruleFactory;
-    this.conditionFactory = conditionFactory;
-    this.actionRequestFactory = actionRequestFactory;
-    this.pluginName = pluginName;
-    reloadRules();
+      @ItsPath Path itsPath,
+      @GlobalRulesFileName String globalRulesFileName,
+      @PluginRulesFileName String pluginRulesFileName,
+      ItsRulesProjectCache rulesProjectCache,
+      RulesConfigReader rulesConfigReader) {
+    this.globalRuleFile = itsPath.resolve(globalRulesFileName).toFile();
+    this.itsSpecificRuleFile = itsPath.resolve(pluginRulesFileName).toFile();
+    this.rulesProjectCache = rulesProjectCache;
+    this.rules =
+        new ImmutableList.Builder<Rule>()
+            .addAll(getRulesFromFile(rulesConfigReader, globalRuleFile))
+            .addAll(getRulesFromFile(rulesConfigReader, itsSpecificRuleFile))
+            .build();
   }
 
   /**
-   * Adds rules from a file to the the RuleBase.
+   * Gets rules from a file.
    *
    * <p>If the given file does not exist, it is silently ignored
    *
+   * @param rulesConfigReader The rules reader
    * @param ruleFile File from which to read the rules
+   * @return A collection of rules or an empty collection if the file does not exist or contains an
+   *     invalid configuration
    */
-  private void addRulesFromFile(File ruleFile) {
+  private static Collection<Rule> getRulesFromFile(
+      RulesConfigReader rulesConfigReader, File ruleFile) {
     if (ruleFile.exists()) {
       FileBasedConfig cfg = new FileBasedConfig(ruleFile, FS.DETECTED);
       try {
         cfg.load();
+        return rulesConfigReader.getRulesFromConfig(cfg);
       } catch (IOException | ConfigInvalidException e) {
         log.error("Invalid ITS action configuration", e);
-        return;
-      }
-
-      Collection<String> subsections = cfg.getSubsections(RULE_SECTION);
-      for (String subsection : subsections) {
-        Rule rule = ruleFactory.create(subsection);
-        Collection<String> keys = cfg.getNames(RULE_SECTION, subsection);
-        for (String key : keys) {
-          String[] values = cfg.getStringList(RULE_SECTION, subsection, key);
-          if (ACTION_KEY.equals(key)) {
-            for (String value : values) {
-              ActionRequest actionRequest = actionRequestFactory.create(value);
-              rule.addActionRequest(actionRequest);
-            }
-          } else {
-            for (String value : values) {
-              Condition condition = conditionFactory.create(key, value);
-              rule.addCondition(condition);
-            }
-          }
-        }
-        rules.add(rule);
       }
     }
-  }
-
-  /** Loads the rules for the RuleBase. */
-  private void reloadRules() {
-    rules = Lists.newArrayList();
-
-    // Add rules from file with typo in filename
-    //
-    // While the documentation called for "actions.config" (Trailing "s" in
-    // "actions"), the code previously only loaded "action.config" (No
-    // trailing "s" in "action"). To give users time to gracefully migrate to
-    // "actions.config" (with trailing "s", we (for now) load files from both
-    // locations, but consider "actions.config" (with trailing "s" the
-    // canonical place.
-    File faultyNameRuleFile =
-        new File(
-            sitePath.toFile(),
-            "etc" + File.separatorChar + "its" + File.separator + "action.config");
-    if (faultyNameRuleFile.exists()) {
-      log.warn(
-          "Loading rules from deprecated 'etc/its/action.config' (No "
-              + "trailing 's' in 'action'). Please migrate to "
-              + "'etc/its/actions.config' (Trailing 's' in 'actions').");
-      addRulesFromFile(faultyNameRuleFile);
-    }
-
-    // Add global rules
-    File globalRuleFile = new File(sitePath.toFile(), ITS_CONFIG_FILE_START + ITS_CONFIG_FILE_END);
-    addRulesFromFile(globalRuleFile);
-
-    // Add its-specific rules
-    File itsSpecificRuleFile =
-        new File(sitePath.toFile(), ITS_CONFIG_FILE_START + "-" + pluginName + ITS_CONFIG_FILE_END);
-    addRulesFromFile(itsSpecificRuleFile);
-
-    if (!globalRuleFile.exists() && !itsSpecificRuleFile.exists()) {
-      try {
-        log.warn(
-            "Neither global rule file "
-                + globalRuleFile.getCanonicalPath()
-                + " nor Its specific rule file"
-                + itsSpecificRuleFile.getCanonicalPath()
-                + " exist. Please configure "
-                + "rules.");
-      } catch (IOException e) {
-        log.warn(
-            "Neither global rule file nor Its specific rule files exist. "
-                + "Please configure rules.");
-      }
-    }
+    return Collections.emptyList();
   }
 
   /**
@@ -168,11 +93,23 @@
    * @param properties The properties to search actions for.
    * @return Requests for the actions that should be fired.
    */
-  public Collection<ActionRequest> actionRequestsFor(Iterable<Property> properties) {
-    Collection<ActionRequest> ret = Lists.newLinkedList();
-    for (Rule rule : rules) {
-      ret.addAll(rule.actionRequestsFor(properties));
+  public Collection<ActionRequest> actionRequestsFor(Map<String, String> properties) {
+    String projectName = properties.get("project");
+    Collection<Rule> fromProjectConfig = rulesProjectCache.get(projectName);
+    Collection<Rule> rulesToAdd = !fromProjectConfig.isEmpty() ? fromProjectConfig : rules;
+    if (rulesToAdd.isEmpty() && !globalRuleFile.exists() && !itsSpecificRuleFile.exists()) {
+      log.warn(
+          "Neither global rule file {} nor Its specific rule file {} exist and no rules are "
+              + "configured for project {}. Please configure rules.",
+          globalRuleFile,
+          itsSpecificRuleFile,
+          projectName);
+      return Collections.emptyList();
     }
-    return ret;
+    Collection<ActionRequest> actions = new ArrayList<>();
+    for (Rule rule : rulesToAdd) {
+      actions.addAll(rule.actionRequestsFor(properties));
+    }
+    return actions;
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RulesConfigReader.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RulesConfigReader.java
new file mode 100644
index 0000000..e4c0f0c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/RulesConfigReader.java
@@ -0,0 +1,72 @@
+// 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.its.base.workflow;
+
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.Collection;
+import org.eclipse.jgit.lib.Config;
+
+public class RulesConfigReader {
+
+  /** The section for rules within rulebases */
+  static final String RULE_SECTION = "rule";
+
+  /** The key for actions within rulebases */
+  static final String ACTION_KEY = "action";
+
+  private final Rule.Factory ruleFactory;
+  private final Condition.Factory conditionFactory;
+  private final ActionRequest.Factory actionRequestFactory;
+
+  @Inject
+  RulesConfigReader(
+      Rule.Factory ruleFactory,
+      Condition.Factory conditionFactory,
+      ActionRequest.Factory actionRequestFactory) {
+    this.ruleFactory = ruleFactory;
+    this.conditionFactory = conditionFactory;
+    this.actionRequestFactory = actionRequestFactory;
+  }
+
+  Collection<Rule> getRulesFromConfig(Config cfg) {
+    Collection<Rule> rules = new ArrayList<>();
+    for (String subsection : cfg.getSubsections(RULE_SECTION)) {
+      Rule rule = ruleFactory.create(subsection);
+      for (String key : cfg.getNames(RULE_SECTION, subsection)) {
+        String[] values = cfg.getStringList(RULE_SECTION, subsection, key);
+        if (ACTION_KEY.equals(key)) {
+          addActions(rule, values);
+        } else {
+          addConditions(rule, key, values);
+        }
+      }
+      rules.add(rule);
+    }
+    return rules;
+  }
+
+  private void addActions(Rule rule, String[] values) {
+    for (String value : values) {
+      rule.addActionRequest(actionRequestFactory.create(value));
+    }
+  }
+
+  private void addConditions(Rule rule, String key, String[] values) {
+    for (String value : values) {
+      rule.addCondition(conditionFactory.create(key, value));
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/StandardAction.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/StandardAction.java
new file mode 100644
index 0000000..eb23693
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/StandardAction.java
@@ -0,0 +1,18 @@
+// Copyright (C) 2013 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.its.base.workflow;
+
+/** Interface for actions defined by base module */
+interface StandardAction extends Action {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddSoyComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddSoyComment.java
deleted file mode 100644
index bbad6e9..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddSoyComment.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.googlesource.gerrit.plugins.its.base.workflow.action;
-
-import com.google.common.base.Strings;
-import com.google.common.io.CharStreams;
-import com.google.gerrit.server.config.SitePath;
-import com.google.inject.Inject;
-import com.google.inject.ProvisionException;
-import com.google.template.soy.SoyFileSet;
-import com.google.template.soy.data.SanitizedContent;
-import com.google.template.soy.tofu.SoyTofu;
-import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import java.io.File;
-import java.io.IOException;
-import java.io.Reader;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.HashMap;
-import java.util.Set;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Adds a short predefined comments to an issue.
- *
- * <p>Comments are added for merging, abandoning, restoring of changes and adding of patch sets.
- */
-public class AddSoyComment implements Action {
-  private static final Logger log = LoggerFactory.getLogger(AddSoyComment.class);
-
-  public interface Factory {
-    AddSoyComment create();
-  }
-
-  /** Directory (relative to site) to search templates in */
-  private static final String ITS_TEMPLATE_DIR =
-      "etc" + File.separator + "its" + File.separator + "templates";
-
-  private final ItsFacade its;
-  private final Path sitePath;
-  protected HashMap<String, Object> soyContext;
-
-  @Inject
-  public AddSoyComment(@SitePath Path sitePath, ItsFacade its) {
-    this.sitePath = sitePath;
-    this.its = its;
-  }
-
-  private HashMap<String, Object> getSoyContext(Set<Property> properties) {
-    HashMap<String, Object> soyContext = new HashMap<>();
-    for (Property property : properties) {
-      String key = property.getKey();
-      if (!Strings.isNullOrEmpty(key)) {
-        String value = property.getValue();
-        if (!Strings.isNullOrEmpty(value)) {
-          soyContext.put(key, value);
-        }
-      }
-    }
-
-    return soyContext;
-  }
-
-  private String soyTemplate(
-      SoyFileSet.Builder builder,
-      String template,
-      SanitizedContent.ContentKind kind,
-      Set<Property> properties) {
-    Path templateDir = sitePath.resolve(ITS_TEMPLATE_DIR);
-    Path templatePath = templateDir.resolve(template + ".soy");
-    String content;
-
-    try (Reader r = Files.newBufferedReader(templatePath, StandardCharsets.UTF_8)) {
-      content = CharStreams.toString(r);
-    } catch (IOException err) {
-      throw new ProvisionException(
-          "Failed to read template file " + templatePath.toAbsolutePath().toString(), err);
-    }
-
-    builder.add(content, templatePath.toAbsolutePath().toString());
-
-    HashMap<String, Object> context = getSoyContext(properties);
-
-    SoyTofu.Renderer renderer =
-        builder
-            .build()
-            .compileToTofu()
-            .newRenderer("etc.its.templates." + template)
-            .setContentKind(kind)
-            .setData(context);
-    return renderer.render();
-  }
-
-  protected String soyTextTemplate(
-      SoyFileSet.Builder builder, String template, Set<Property> properties) {
-    return soyTemplate(builder, template, SanitizedContent.ContentKind.TEXT, properties);
-  }
-
-  @Override
-  public void execute(String issue, ActionRequest actionRequest, Set<Property> properties)
-      throws IOException {
-    SoyFileSet.Builder builder = SoyFileSet.builder();
-    String template = null;
-    String templateName = actionRequest.getParameter(1);
-    if (templateName.isEmpty()) {
-      log.error("No template name given in {}", actionRequest);
-    } else {
-      template = templateName;
-    }
-    if (!Strings.isNullOrEmpty(template)) {
-      String comment = soyTextTemplate(builder, template, properties);
-      its.addComment(issue, comment);
-    }
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddStandardComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddStandardComment.java
deleted file mode 100644
index eb8c4a6..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddStandardComment.java
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.googlesource.gerrit.plugins.its.base.workflow.action;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Maps;
-import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import java.io.IOException;
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Adds a short predefined comments to an issue.
- *
- * <p>Comments are added for merging, abandoning, restoring of changes and adding of patch sets.
- */
-public class AddStandardComment implements Action {
-  public interface Factory {
-    AddStandardComment create();
-  }
-
-  private final ItsFacade its;
-
-  @Inject
-  public AddStandardComment(ItsFacade its) {
-    this.its = its;
-  }
-
-  private String formatPerson(String prefix, Map<String, String> map) {
-    String ret = Strings.nullToEmpty(map.get(prefix + "-name"));
-    ret = Strings.nullToEmpty(map.get(prefix + "Name"));
-    if (ret.isEmpty()) {
-      ret = Strings.nullToEmpty(map.get(prefix + "-username"));
-      ret = Strings.nullToEmpty(map.get(prefix + "Username"));
-    }
-    return ret;
-  }
-
-  private String getCommentChangeEvent(String action, String prefix, Map<String, String> map) {
-    String ret = "";
-    String changeNumber = getValueFromMap(map, "", "change-number", "changeNumber");
-    if (!changeNumber.isEmpty()) {
-      changeNumber += " ";
-    }
-    ret += "Change " + changeNumber + action;
-    String submitter = getValueFromMap(map, prefix, "-name", "Name", "-username", "Username");
-    if (!submitter.isEmpty()) {
-      ret += " by " + submitter;
-    }
-    String subject = Strings.nullToEmpty(map.get("subject"));
-    if (!subject.isEmpty()) {
-      ret += ":\n" + subject;
-    }
-    String reason = Strings.nullToEmpty(map.get("reason"));
-    if (!reason.isEmpty()) {
-      ret += "\n\nReason:\n" + reason;
-    }
-    String url = getValueFromMap(map, "", "change-url", "changeUrl");
-    if (!url.isEmpty()) {
-      ret += "\n\n" + its.createLinkForWebui(url, url);
-    }
-    return ret;
-  }
-
-  private String getValueFromMap(Map<String, String> map, String keyPrefix, String... keyOptions) {
-    for (String key : keyOptions) {
-      String ret = Strings.nullToEmpty(map.get(keyPrefix + key));
-      if (!ret.isEmpty()) {
-        return ret;
-      }
-    }
-    return "";
-  }
-
-  private String getCommentChangeAbandoned(Map<String, String> map) {
-    return getCommentChangeEvent("abandoned", "abandoner", map);
-  }
-
-  private String getCommentChangeMerged(Map<String, String> map) {
-    return getCommentChangeEvent("merged", "submitter", map);
-  }
-
-  private String getCommentChangeRestored(Map<String, String> map) {
-    return getCommentChangeEvent("restored", "restorer", map);
-  }
-
-  private String getCommentPatchSetCreated(Map<String, String> map) {
-    return getCommentChangeEvent("had a related patch set uploaded", "uploader", map);
-  }
-
-  @Override
-  public void execute(String issue, ActionRequest actionRequest, Set<Property> properties)
-      throws IOException {
-    String comment = "";
-    Map<String, String> map = Maps.newHashMap();
-    for (Property property : properties) {
-      String current = property.getValue();
-      if (!Strings.isNullOrEmpty(current)) {
-        String key = property.getKey();
-        String old = Strings.nullToEmpty(map.get(key));
-        if (!old.isEmpty()) {
-          old += ", ";
-        }
-        map.put(key, old + current);
-      }
-    }
-    String eventType = map.get("event-type");
-    if ("change-abandoned".equals(eventType)) {
-      comment = getCommentChangeAbandoned(map);
-    } else if ("change-merged".equals(eventType)) {
-      comment = getCommentChangeMerged(map);
-    } else if ("change-restored".equals(eventType)) {
-      comment = getCommentChangeRestored(map);
-    } else if ("patchset-created".equals(eventType)) {
-      comment = getCommentPatchSetCreated(map);
-    }
-    if (!Strings.isNullOrEmpty(comment)) {
-      its.addComment(issue, comment);
-    }
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddVelocityComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddVelocityComment.java
deleted file mode 100644
index 13a6655..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddVelocityComment.java
+++ /dev/null
@@ -1,150 +0,0 @@
-// Copyright (C) 2013 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.its.base.workflow.action;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.server.config.SitePath;
-import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import java.io.File;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.Set;
-import org.apache.commons.lang.StringUtils;
-import org.apache.velocity.VelocityContext;
-import org.apache.velocity.runtime.RuntimeInstance;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Adds a short predefined comments to an issue.
- *
- * <p>Comments are added for merging, abandoning, restoring of changes and adding of patch sets.
- */
-public class AddVelocityComment implements Action {
-  private static final Logger log = LoggerFactory.getLogger(AddVelocityComment.class);
-
-  public interface Factory {
-    AddVelocityComment create();
-  }
-
-  /** Directory (relative to site) to search templates in */
-  private static final String ITS_TEMPLATE_DIR =
-      "etc" + File.separator + "its" + File.separator + "templates";
-
-  private final ItsFacade its;
-  private final Path sitePath;
-  private final RuntimeInstance velocityRuntime;
-
-  @Inject
-  public AddVelocityComment(
-      RuntimeInstance velocityRuntime, @SitePath Path sitePath, ItsFacade its) {
-    this.velocityRuntime = velocityRuntime;
-    this.sitePath = sitePath;
-    this.its = its;
-  }
-
-  private VelocityContext getVelocityContext(Set<Property> properties) {
-    VelocityContext velocityContext = new VelocityContext();
-    for (Property property : properties) {
-      String key = property.getKey();
-      if (!Strings.isNullOrEmpty(key)) {
-        String value = property.getValue();
-        if (!Strings.isNullOrEmpty(value)) {
-          velocityContext.put(key, value);
-        }
-      }
-    }
-
-    velocityContext.put("its", new VelocityAdapterItsFacade(its));
-
-    return velocityContext;
-  }
-
-  private String velocify(String template, Set<Property> properties) {
-    VelocityContext context = getVelocityContext(properties);
-    StringWriter w = new StringWriter();
-    velocityRuntime.evaluate(context, w, "ItsComment", template);
-    return w.toString();
-  }
-
-  @Override
-  public void execute(String issue, ActionRequest actionRequest, Set<Property> properties)
-      throws IOException {
-    String template = null;
-    String templateName = actionRequest.getParameter(1);
-    if ("inline".equals(templateName)) {
-      String[] allParameters = actionRequest.getParameters();
-      String[] templateParameters = Arrays.copyOfRange(allParameters, 1, allParameters.length);
-      template = StringUtils.join(templateParameters, " ");
-    } else {
-      if (templateName.isEmpty()) {
-        log.error("No template name given in {}", actionRequest);
-      } else {
-        Path templateDir = sitePath.resolve(ITS_TEMPLATE_DIR);
-        Path templatePath = templateDir.resolve(templateName + ".vm");
-        if (Files.isReadable(templatePath)) {
-          template = new String(Files.readAllBytes(templatePath));
-        } else {
-          log.error("Cannot read template {}", templatePath);
-        }
-      }
-    }
-    if (!Strings.isNullOrEmpty(template)) {
-      String comment = velocify(template, properties);
-      its.addComment(issue, comment);
-    }
-  }
-
-  /** Adapter for ItsFacade to be used through Velocity */
-  // Although we'd prefer to keep this class private, Velocity will only pick
-  // it up, if it is public.
-  public class VelocityAdapterItsFacade {
-
-    private final ItsFacade facade;
-
-    private VelocityAdapterItsFacade(ItsFacade facade) {
-      this.facade = facade;
-    }
-
-    /**
-     * Format a link to a URL in the used Its' syntax.
-     *
-     * @param url URL to link to
-     * @param caption Text used to represent the link
-     * @return Link to the given URL in the used Its' syntax.
-     */
-    public String formatLink(String url, String caption) {
-      return facade.createLinkForWebui(url, caption);
-    }
-
-    /**
-     * Format a link to an URL.
-     *
-     * <p>The provided URL is used as caption for the formatted link.
-     *
-     * @param url URL to link to
-     * @return Link to the given URL in the used Its' syntax.
-     */
-    public String formatLink(String url) {
-      return facade.createLinkForWebui(url, url);
-    }
-  }
-}
diff --git a/src/main/resources/Documentation/config-common.md b/src/main/resources/Documentation/config-common.md
index d53b0c6..666481f 100644
--- a/src/main/resources/Documentation/config-common.md
+++ b/src/main/resources/Documentation/config-common.md
@@ -1,17 +1,17 @@
-Common configuration for `its-base`-based plugins
-=================================================
+# Common configuration for `its-base`-based plugins
 
-#### Table of Contents
-* [Identifying ITS ids][identifying-its-ids]
-* [Enabling ITS integration][enabling-its-integration]
-* [Configuring rules of when to take which actions in the ITS][configure-rules]
-* [Further common configuration details][config-common-detail]
+[TOC]: # "Table of Contents"
+
+### Table of Contents
+- [Identifying ITS ids](#identifying-its-ids)
+- [Enabling ITS integration](#enabling-its-integration)
+- [Associating a Gerrit project with its ITS project counterpart](#associating-a-gerrit-project-with-its-its-project-counterpart)
+- [Configuring rules of when to take which actions in the ITS](#configuring-rules-of-when-to-take-which-actions-in-the-its)
+- [Multiple Its](#multiple-its)
+- [Further common configuration details](#further-common-configuration-details)
 
 
-
-[identifying-its-ids]: #identifying-its-ids
-<a name="identifying-its-ids">Identifying ITS ids</a>
------------------------------------------------------
+## Identifying ITS ids
 
 In order to extract ITS ids from commit messages, @PLUGIN@ uses
 [commentlink][upstream-comment-link-doc]s of
@@ -35,15 +35,15 @@
 Sample commit message relating to bug 4711, and bug 167.
 ```
 
-[upstream-comment-link-doc]: ../../../Documentation/config-gerrit.html#commentlink
+[upstream-comment-link-doc](../../../Documentation/config-gerrit.html#commentlink)
 
 By setting a `commentlink`'s `association` on the plugin's @PLUGIN@ configuration, it
 is possible to require commits to carry ITS references; the following
 values are supported (default is `OPTIONAL`):
 
 MANDATORY
-:	 One or more issue-ids are required in the git commit message, otherwise
-	 the git push will be rejected.
+:	 One or more issue-ids are required in the git commit message.  The git push will
+	 be rejected otherwise.
 
 SUGGESTED
 :	 Whenever the git commit message does not contain one or more issue-ids,
@@ -72,9 +72,7 @@
 using the same syntax used in the gerrit.config. Project's hierarchy will be respected
 when evaluating the links configuration and association policy.
 
-[enabling-its-integration]: #enabling-its-integration
-<a name="enabling-its-integration">Enabling ITS integration</a>
----------------------------------------------------------------
+## Enabling ITS integration
 
 It can be configured per project whether the issue tracker
 integration is enabled or not. To enable the issue tracker integration
@@ -122,11 +120,22 @@
     branch = ^refs/heads/stable-.*
 ```
 
+## Associating a Gerrit project with its ITS project counterpart
 
+To be able to make use of actions acting at the ITS project level, you must
+associate a Gerrit project to its ITS project counterpart.
 
-[configure-rules]: #configure-rules
-<a name="configure-rules">Configuring rules of when to take which actions in the ITS</a>
-----------------------------------------------------------------------------------------
+It must be configured per project and per plugin. To configure the association
+for a project mapping to an ITS project named `manhattan-project`, the project
+must have the following entry in its `project.config` file in the
+`refs/meta/config` branch:
+
+```
+  [plugin "@PLUGIN@"]
+    its-project = manhattan-project
+```
+
+## Configuring rules of when to take which actions in the ITS
 
 Setting up which event in Gerrit (E.g.: “Change Merged”, or “User
 ‘John Doe’ voted ‘+2’ for ‘Code-Review’ on a change”) should trigger
@@ -138,9 +147,7 @@
 
 
 
-[multiple-its]: #multiple-its
-<a name="mutiple-its">Multiple ITS</a>
---------------------------------------
+## Multiple ITS
 
 Although not a common setup the @PLUGIN@ plugin supports connecting
 Gerrit to multiple issue tracking systems.
@@ -160,15 +167,12 @@
 just need to use the appropriate name to configure each plugin.
 
 
+## Further common configuration details
 
-[config-common-detail]: #config-common-detail
-<a name="config-common-detail">Further common configuration details</a>
------------------------------------------------------------------------
+[common-config-commentlink](#common-config-commentlink)
+[common-config-commentlinkGroupIndex](#common-config-commentlinkGroupIndex)
 
-[common-config-commentlink]: #common-config-commentlink
-[common-config-commentlinkGroupIndex]: #common-config-commentlinkGroupIndex
-
-<a name="common-config-commentlink">`@PLUGIN@.commentlink`
+<a name="common-config-commentlink">`@PLUGIN@.commentlink`</a>
 :   The name of the comment link to use to extract issue ids.
 
     This setting is useful to reuse the same comment link from different Its
@@ -178,13 +182,19 @@
 
     Default is `@PLUGIN@`
 
-<a name="common-config-commentlinkGroupIndex">`@PLUGIN@.commentlinkGroupIndex`
+<a name="common-config-commentlinkGroupIndex">`@PLUGIN@.commentlinkGroupIndex`</a>
 :   The group index within `@PLUGIN@.commentlink` that holds the issue id.
 
     Default is `1`, if there are are groups within the regular expression for
     the `@PLUGIN@.commentlink` comment link, and the default is `0`, if there
     are no such groups.
 
+<a name="common-config-dummyIssuePattern">`@PLUGIN@.dummyIssuePattern`</a>
+:   Pattern which can be specified to match a dummy issue.
+
+    This setting is useful to bypass the MANDATORY check for commits matching
+    a specific pattern.
+
 [Back to @PLUGIN@ documentation index][index]
 
 [index]: index.html
diff --git a/src/main/resources/Documentation/config-rulebase-common.md b/src/main/resources/Documentation/config-rulebase-common.md
index 16f2de5..17baf77 100644
--- a/src/main/resources/Documentation/config-rulebase-common.md
+++ b/src/main/resources/Documentation/config-rulebase-common.md
@@ -1,16 +1,17 @@
-Rule base configuration
-=======================
+# Rule base configuration
 
-#### Table of Contents
-* [Overview][overview]
-* [Rules][rules]
-* [Conditions][conditions]
-* [Event Properties][event-properties]
-* [Actions][actions]
+[TOC]: # "Table of Contents"
 
-[overview]: #overview
-<a name="overview">Overview</a>
--------------------------------
+### Table of Contents
+- [Overview](#overview)
+- [Rule Bases Scope](#rule-bases-scope)
+- [Rules](#rules)
+- [Conditions](#conditions)
+- [Event Properties](#event-properties)
+- [Actions](#actions)
+
+
+## Overview
 
 In this part we describe how to specify which events in Gerrit (E.g.:
 “Change Merged”, or “User ‘John Doe’ voted ‘+2’ for ‘Code-Review’ on a
@@ -18,16 +19,12 @@
 ‘Resolved’”) on the ITS.
 
 Actions on the ITS and conditions for the action to take place are
-configured through the rule bases in `etc/its/actions.config` (for global rules
-to be picked up by all configured ITS plugins) and
-`etc/its/actions-@PLUGIN@.config` (for rules to be picked up only by @PLUGIN@)
-in the site directory. A rule base is a git config file, and may contain an
-arbitrary number of rules. Each rule can have an arbitrary number of conditions
-and actions. A rule fires all associated actions, once all of its conditions are
-met.
+configured through rule bases.  A rule base is a git config file and
+may contain an arbitrary number of rules. Each rule can have an
+arbitrary number of conditions and actions. A rule fires all associated
+actions, once all of its conditions are met.
 
-A simple `etc/its/actions.config` (or
-`etc/its/actions-@PLUGIN@.config`) may look like
+A simple rule bases file may look like
 
 ```
 [rule "rule1"]
@@ -47,13 +44,92 @@
 Goodness! Someone gave a negative code review in Gerrit on an
 associated change.” to each such issue.
 
-The order of rules in `etc/its/actions.config` need not be
-respected. So in the above example, do not rely on `rule1` being
-evaluated before `rule2`.
+The order of rules in a rule base file need not be respected. So in the
+above example, do not rely on `rule1` being evaluated before `rule2`.
 
-[rules]: #rules
-<a name="rules">Rules</a>
--------------------------
+## Rule bases scope
+
+Rule bases can be defined in two scopes:
+
+* Global
+* Plugin specific
+
+Global rules are picked up by all configured ITS plugins and they are
+defined in a rule base file named `actions.config`.
+
+Plugin specific rules are picked up only by @PLUGIN@ plugin and they
+are defined in a rule base file named `actions-@PLUGIN@.config`.
+
+A second aspect of rules bases scope is what projects they apply to;
+in this case the rules can be:
+
+* Generic
+* Project specific
+
+The generic scope refers to rules that apply to all projects that
+enable ITS integration on the gerrit site; they are defined on rule
+base files located inside the `gerrit_site/etc/its/` folder.
+
+Project-specific rules are defined on rule base files located on the
+`refs/meta/config` branch of a project and they apply exclusively to
+the project that defines them (or that inherits them) and, possibly,
+to child projects (see further explanations about rules inheritance
+below). Project specific rules take precedence over generic rules,
+i.e, when they are defined, generic rules do not apply to the project.
+
+Thus, to define global generic rules, i.e., rules that are picked up
+by all the ITS plugins and that apply to all the projects that enable
+any ITS integration, they should be defined in the
+`gerrit_site/etc/its/actions.config` rule base file.
+
+Rules defined in the `gerrit_site/etc/its/actions-@PLUGIN@.config` rule
+base file have generic but plugin specific scope, i.e., they apply to
+all projects on the gerrit site that enable integration with @PLUGIN@.
+
+On the other hand, if the rule base file `actions.config` is created
+on the `refs/meta/config` branch of project 'P', the rules defined
+on this file will have global but project specific scope, i.e, they
+apply to all the ITS integrations defined for this project. Thus, if
+project 'P' integrates with ITS system 'x' and with ITS system 'y',
+the rules are applied to these two ITS integrations.
+
+Contrarily, rules defined on the rule base file `actions-@PLUGIN@.config`
+created on the `refs/meta/config` branch of project 'P' have project
+and plugin specific scope, i.e., they apply only to the @PLUGIN@
+integration defined for project 'P'.
+
+Finally, is important to notice that if global and plugin specific rules
+are defined, the final set of rules applied is the merge of them and this
+is true either if they are defined in generic or project specific scope.
+
+[rules-inheritance]: #rules-inheritance
+### <a name="rules-inheritance">Rules inheritance</a>
+
+For project specific rules, i.e., those defined on the `refs/meta/config`
+branch of a project, inheritance is honored, similar to what is done in
+other cases of project configurations.
+
+Thus, if project 'P' defines project specific rules, these are applied
+to children projects of project 'P' that enable an ITS integration.
+
+This inheritance, however, is capped at the closest level, which means
+that if a project defines at least one of the rule bases files,
+(`actions.config` or `actions-@PLUGIN@.config`), the presence of these
+files is not evaluated for any of the project's parents.
+
+Thence, and continuing the example, if project 'Q' is a child of
+project 'P' and project 'Q' enables ITS integration, rules defined in
+project 'P' apply to project 'Q'. If however, project 'Q' defines its
+own set of rules, either on file `actions.config` or
+`actions-@PLUGIN@.config` (or both), the rules defined by project 'P'
+no longer apply to project 'Q' , i.e., rules defined in project 'Q'
+override and replace any rules defined on any parent project.
+
+The same applies to the rules defined at the site level: if any project
+defines their own rule base files, the global ones defined in the
+`gerrit_site/etc/its/` folder do not apply to this project.
+
+## Rules
 
 Each rule consists of three items: A name, a set of conditions, and a
 set of actions.
@@ -72,9 +148,7 @@
 There is no upper limit on the number of elements in a rules set of
 conditions, and set of actions. Each of those sets may be empty.
 
-[conditions]: #conditions
-<a name="conditions">Conditions</a>
------------------------------------
+## Conditions
 
 The conditions are lines of the form
 
@@ -101,9 +175,7 @@
   status = !,DRAFT
 ```
 
-[event-properties]: #event-properties
-<a name="event-properties">Event Properties</a>
------------------------------------------------
+## Event Properties
 
 The properties exposed by events depend on the kind of event.
 
@@ -135,7 +207,7 @@
     make a rule in the rulebase match only for certain ITS plugins, if more
     than one is installed.
 
-    For example
+For example
 
     ```
     [rule "someRuleForBugzillaOnly"]
@@ -148,25 +220,27 @@
       action = add-comment Dear JIRA users, the change had a -2 Code-Review approval.
     ```
 
-    would report the “Heya Bugzilla...” text only through its-bugzilla for
-    changes that had a -2 Code-Review and have an association through
-    its-bugzilla. And for changes that had a -2 Code-Review and have an
-    association through its-jira, its-jira would report “Dear Jira users, ...”.
+would report the “Heya Bugzilla...” text only through its-bugzilla for
+changes that had a -2 Code-Review and have an association through
+its-bugzilla. And for changes that had a -2 Code-Review and have an
+association through its-jira, its-jira would report “Dear Jira users, ...”.
 
 The further properties are listed in the event's
 corresponding subsection below:
 
-* [ChangeAbandonedEvent][event-properties-ChangeAbandonedEvent]
-* [ChangeMergedEvent][event-properties-ChangeMergedEvent]
-* [ChangeRestoredEvent][event-properties-ChangeRestoredEvent]
-* [CommentAddedEvent][event-properties-CommentAddedEvent]
-* [PatchSetCreatedEvent][event-properties-PatchSetCreatedEvent]
-* [RefUpdatedEvent][event-properties-RefUpdatedEvent]
-* [Common properties for events on a change][event-properties-change]
-* [Common properties for events on a patch set][event-properties-patch-set]
+* [Property: `association`](#property-association)
+* [ChangeAbandonedEvent](#changeabandonedevent)
+* [ChangeMergedEvent](#changemergedevent)
+* [ChangeRestoredEvent](#changerestoredevent)
+* [CommentAddedEvent](#commentaddedevent)
+* [PatchSetCreatedEvent](#patchSetcreatedevent)
+* [RefUpdatedEvent](#refupdatedevent)
+* [WorkInProgressStateChangedEvent](#workinprogressstatechangedevent)
+* [PrivateStateChangedEvent](#privatestatechangedevent)
+* [Common properties for events on a change](#common-properties-for-events-on-a-change)
+* [Common properties for events on a patch set](#common-properties-for-events-on-a-patch-set)
 
-[property-association]: #property-association
-### <a name="property-association">Property: `association`</a>
+### Property: `association`
 
 The property `association` describes how the `issue` got associated to
 this event.
@@ -175,53 +249,52 @@
 values are:
 
 `somewhere`
-:   issue id occurs somewhere in the commit message of the change/the
-    most recent patch set.
+:	issue id occurs somewhere in the commit message of the change/the
+	most recent patch set.
 
 `subject`
-:   issue id occurs in the first line of the commit message of the
-    change/the most recent patch set.
+:	issue id occurs in the first line of the commit message of the
+	change/the most recent patch set.
 
 `body`
-:   issue id occurs after the subject but before the footer of the
-    commit message of the change/the most recent patch set.
+:	issue id occurs after the subject but before the footer of the
+	commit message of the change/the most recent patch set.
 
 `footer`
-:   issue id occurs in the last paragraph after the subject of the
-    commit message of the change/the most recent patch set
+:	issue id occurs in the last paragraph after the subject of the
+	commit message of the change/the most recent patch set
 
 `footer-<Key>`
-:   issue id occurs in the footer of the commit message of the
-    change/the most recent patch set, and is in a line with a key
-    (part before the colon).
+:	issue id occurs in the footer of the commit message of the
+	change/the most recent patch set, and is in a line with a key
+	(part before the colon).
 
-    So for example, if the footer would contain a line
+So for example, if the footer would contain a line
 
-    ```
+```
 Fixes-Issue: issue 4711
 ```
 
-    then a property `association` with value `footer-Fixes-Issue`
-    would get added to the event for issue “4711”.
+then a property `association` with value `footer-Fixes-Issue`
+would get added to the event for issue “4711”.
 
 `added@<Association-Value>`
-:   (only for events that allow to determine the patch set number.
-    So for example, this `association` property is not set for
-    RevUpdatedEvents)
+:	(only for events that allow to determine the patch set number.
+	So for example, this `association` property is not set for
+	RevUpdatedEvents)
 
-    issue id occurs at `<Association-Value>` in the most recent
-    patch set of the change, and either the event is for patch set
-    1 or the issue id does not occur at `<Association-Value>` in
-    the previous patch set.
+issue id occurs at `<Association-Value>` in the most recent
+patch set of the change, and either the event is for patch set
+1 or the issue id does not occur at `<Association-Value>` in
+the previous patch set.
 
-    So for example if issue “4711” occurs in the subject of patch
-    set 3 (the most recent patch set) of a change, but not in
-    patch set 2.  When adding a comment to this change, the event
-    for issue “4711” would get a property 'association' with value
-    `added@subject`.
+So for example if issue “4711” occurs in the subject of patch
+set 3 (the most recent patch set) of a change, but not in
+patch set 2.  When adding a comment to this change, the event
+for issue “4711” would get a property 'association' with value
+`added@subject`.
 
-[event-properties-ChangeAbandonedEvent]: #event-properties-ChangeAbandonedEvent
-### <a name="event-properties-ChangeAbandonedEvent">ChangeAbandonedEvent</a>
+### ChangeAbandonedEvent
 
 `abandonerEmail`
 : email address of the user abandoning the change.
@@ -242,11 +315,10 @@
 : reason why the change has been abandoned.
 
 In addition to the above properties, the event also provides
-properties for the abandoned [Change][event-properties-change], and
-its most recent [Patch Set][event-properties-patch-set].
+properties for the abandoned [Change][common-properties-for-events-on-a-change], and
+its most recent [Patch Set][common-properties-for-events-on-a-patch-set].
 
-[event-properties-ChangeMergedEvent]: #event-properties-ChangeMergedEvent
-### <a name="event-properties-ChangeMergedEvent">ChangeMergedEvent</a>
+### ChangeMergedEvent
 
 `event`
 : `com.google.gerrit.server.events.ChangeMergedEvent`
@@ -264,11 +336,10 @@
 : username of the user causing the merge of the change.
 
 In addition to the above properties, the event also provides
-properties for the merged [Change][event-properties-change], and its
-most recent [Patch Set][event-properties-patch-set].
+properties for the merged [Change][common-properties-for-events-on-a-change], and its
+most recent [Patch Set][common-properties-for-events-on-a-patch-set].
 
-[event-properties-ChangeRestoredEvent]: #event-properties-ChangeRestoredEvent
-### <a name="event-properties-ChangeRestoredEvent">ChangeRestoredEvent</a>
+### ChangeRestoredEvent
 
 `event`
 : `com.google.gerrit.server.events.ChangeRestoredEvent`
@@ -289,11 +360,10 @@
 : username of the user restoring the change.
 
 In addition to the above properties, the event also provides
-properties for the restored [Change][event-properties-change], and it's
-most recent [Patch Set][event-properties-patch-set].
+properties for the restored [Change][common-properties-for-events-on-a-change], and it's
+most recent [Patch Set][common-properties-for-events-on-a-patch-set].
 
-[event-properties-CommentAddedEvent]: #event-properties-CommentAddedEvent
-### <a name="event-properties-CommentAddedEvent">CommentAddedEvent</a>
+### CommentAddedEvent
 
 NOTE: For consistency with the other events, the `author-...`
 properties of the CommentAddedEvent do not refer to the author of the
@@ -314,7 +384,7 @@
 : added comment itself.
 
 `event`
-: `com.google.gerrit.server.events.CommentAddedEvent+
+: `com.google.gerrit.server.events.CommentAddedEvent+`
 
 `event-type`
 : `comment-added`
@@ -328,11 +398,10 @@
 : `-2`
 
 In addition to the above properties, the event also provides
-properties for the [Change][event-properties-change] the comment was
-added for, and it's most recent [Patch Set][event-properties-patch-set].
+properties for the [Change][common-properties-for-events-on-a-change] the comment was
+added for, and it's most recent [Patch Set][common-properties-for-events-on-a-patch-set].
 
-[event-properties-PatchSetCreatedEvent]: #event-properties-PatchSetCreatedEvent
-### <a name="event-properties-PatchSetCreatedEvent">PatchSetCreatedEvent</a>
+### PatchSetCreatedEvent
 
 `event`
 : `com.google.gerrit.server.events.PatchSetCreatedEvent`
@@ -341,11 +410,10 @@
 : `patchset-created`
 
 In addition to the above properties, the event also provides
-properties for the uploaded [Patch Set][event-properties-patch-set],
-and the [Change][event-properties-change] it belongs to.
+properties for the uploaded [Patch Set][common-properties-for-events-on-a-patch-set],
+and the [Change][common-properties-for-events-on-a-change] it belongs to.
 
-[event-properties-RefUpdatedEvent]: #event-properties-RefUpdatedEvent
-### <a name="event-properties-RefUpdatedEvent">RefUpdatedEvent</a>
+### RefUpdatedEvent
 
 `event`
 : `com.google.gerrit.server.events.RefUpdatedEvent`
@@ -375,8 +443,41 @@
 `submitterUsername`
 : username of the user that updated the ref.
 
-[event-properties-change]: #event-properties-change
-### <a name="event-properties-change">Common properties for events on a change</a>
+### WorkInProgressStateChangedEvent
+
+`event`
+:   `com.google.gerrit.server.events.WorkInProgressStateChangedEvent`
+
+`event-type`
+:   `wip-state-changed`
+
+`changerEmail`
+:   email address of the user that changed the WIP state
+
+`submitterName`
+:   name of the user that changed the WIP state
+
+`changerUsername`
+:   username of the user that changed the WIP state
+
+### PrivateStateChangedEvent
+
+`event`
+:   `com.google.gerrit.server.events.PrivateStateChangedEvent`
+
+`event-type`
+:   `private-state-changed`
+
+`changerEmail`
+:   email address of the user that changed the private state
+
+`submitterName`
+:   name of the user that changed the private state
+
+`changerUsername`
+:   username of the user that changed the private state
+
+### Common properties for events on a change
 
 `branch`
 : name of the branch the change belongs to.
@@ -418,8 +519,13 @@
 `topic`
 : name of the topic the change belongs to.
 
-[event-properties-patch-set]: #event-properties-patch-set
-### <a name="event-properties-patch-set">Common properties for events on a patch set</a>
+`private`
+:   whether the change is marked private
+
+`wip`
+:   whether the change is marked work in progress (WIP)
+
+### Common properties for events on a patch set
 
 `authorEmail`
 : email address of this patch set's author.
@@ -461,9 +567,7 @@
 `uploaderUsername`
 : username of the user that uploaded this patch set.
 
-[actions]: #actions
-<a name="actions">Actions</a>
------------------------------
+## Actions
 
 Lines of the form
 
@@ -482,12 +586,12 @@
 [`add-standard-comment`][action-add-standard-comment]
 : adds a predefined standard comment for certain events
 
-[`add-velocity-comment`][action-add-velocity-comment]
-: adds a rendered Velocity template as issue comment
-
-[`add-soy-comment`][action-add-velocity-comment]
+[`add-soy-comment`][action-add-soy-comment]
 : adds a rendered Closure Template (soy) template as issue comment
 
+[`create-version-from-property`][action-create-version-from-property]
+: creates a version based on an event's property value
+
 [`log-event`][action-log-event]
 : appends the event's properties to Gerrit's log
 
@@ -495,8 +599,7 @@
 
 [further-actions]: config-rulebase-plugin-actions.md
 
-[action-add-comment]: #action-add-comment
-### <a name="action-add-comment">Action: add-comment</a>
+### Action: add-comment
 
 The `add-comment` action adds the given parameters as comment to any
 associated rule.
@@ -511,8 +614,7 @@
 
 If no parameters are given, no comment gets added.
 
-[action-add-standard-comment]: #action-add-standard-comment
-### <a name="action-add-standard-comment">Action: add-standard-comment</a>
+### Action: add-standard-comment
 
 The `add-standard-comment` action adds predefined comments to
 associated issues for change abandoned, merged, restored, and patch
@@ -523,69 +625,7 @@
 (abandoner, merger, ...), the change's subject, a reason (if one has
 been given), and a link to the change.
 
-[action-add-velocity-comment]: #action-add-velocity-comment
-### <a name="action-add-velocity-comment">Action: add-velocity-comment</a>
-
-The `add-velocity-comment` action renders a Velocity template for the
-event and adds the output as comment to any associated issue.
-
-So for example
-
-```
-  action = add-velocity-comment TemplateName
-```
-
-would render the template `etc/its/templates/TemplateName.vm` add the
-output as comment to associated issues.
-
-If 'TemplateName' is `inline`, the Velocity template to render is not
-loaded from a file, but the template is built by joining the remaining
-parameters. So for example
-
-```
-  action = add-velocity-comment inline Sample template using $subject property.
-```
-
-would render “Sample template using $subject property.” as Velocity
-template.
-
-If 'TemplateName' is not `inline`, further parameters get ignored.
-
-Any [property][event-properties] of the event may be used from
-templates. So for example `$subject` in the above example refers to
-the event's subject property, and `$changeNumber` would refer to the
-change's number.
-
-Additionally, the context's `its` property provides an object that
-allows to format links using the its' syntax:
-
-`formatLink( url )`
-:   Formats a link to a url.
-
-    So for example upon adding a comment to a change, the
-    following rule formats a link to the change:
-
-    ```
-[rule "formatLinkSampleRule"]
-  event-type = comment-added
-  action = add-velocity-comment inline Comment for change $change-number added. See ${its.formatLink($changeUrl)}
-```
-
-`formatLink( url, caption )`
-:   Formats a link to a url using 'caption' to represent the url.
-
-    So for example upon adding a comment to a change, the following rule
-    formats a link to the change using the change number as link
-    capition:
-
-    ```
-[rule "formatLinkSampleRule"]
-  event-type = comment-added
-  action = add-velocity-comment inline Comment for change ${its.formatLink($changeUrl, $changeNumber)} added.
-```
-
-[action-add-soy-comment]: #action-add-soy-comment
-### <a name="action-add-soy-comment">Action: add-soy-comment</a>
+### Action: add-soy-comment
 
 The `add-soy-comment` action renders a Closure template (soy) for the
 event and adds the output as comment to any associated issue.
@@ -619,8 +659,34 @@
 the event's subject property, and `$changeNumber` would refer to the
 change's number.
 
-[action-log-event]: #action-log-event
-### <a name="action-log-event">Action: log-event</a>
+### Action: add-property-to-field
+
+The `add-property-to-field` action adds an event property value to an ITS designated field.
+
+The field is expected to be able to hold multiple values.
+The ITS field value deduplication depends on the its implementation.
+
+Example with the event property `branch` and a field identified as `labels`:
+
+```
+  action = add-property-to-field branch labels
+```
+
+### Action: create-version-from-property
+
+The `create-version-from-property` action creates a version in the ITS project
+by using an event property value as the version value.
+
+This is useful when you want to create a new version in the ITS when a tag is
+created in the Gerrit project.
+
+Example with the event property `ref`:
+
+```
+  action = create-version-from-property ref
+```
+
+### Action: log-event
 
 The `log-event` action appends the event's properties to Gerrit's log.
 
diff --git a/src/main/templates/Documentation/about.md b/src/main/templates/Documentation/about.md
index 343ec34..6c8b47d 100644
--- a/src/main/templates/Documentation/about.md
+++ b/src/main/templates/Documentation/about.md
@@ -8,11 +8,13 @@
 
 * [its-bugzilla][its-bugzilla]
 * [its-jira][its-jira]
+* [its-phabricator][its-phabricator]
 * [its-rtc][its-rtc]
 
-[its-bugzilla]: https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-bugzilla
-[its-jira]: https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-jira
-[its-rtc]: https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-rtc
+[its-bugzilla]: https://gerrit-review.googlesource.com/admin/repos/plugins/its-bugzilla
+[its-jira]: https://gerrit-review.googlesource.com/admin/repos/plugins/its-jira
+[its-phabricator]: https://gerrit-review.googlesource.com/admin/repos/plugins/its-phabricator
+[its-rtc]: https://gerrit-review.googlesource.com/admin/repos/plugins/its-rtc
 
 `its-base` provides means to:
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfigTest.java
index 6561d00..1a147c7 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfigTest.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.its.base.its;
 
+import static org.easymock.EasyMock.eq;
 import static org.easymock.EasyMock.expect;
 
 import com.google.common.base.Suppliers;
@@ -28,8 +29,8 @@
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.ChangeRestoredEvent;
 import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.PatchSetCreatedEvent;
+import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.events.RefUpdatedEvent;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -38,6 +39,7 @@
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
 import com.googlesource.gerrit.plugins.its.base.validation.ItsAssociationPolicy;
 import java.util.Arrays;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 
 public class ItsConfigTest extends LoggingMockingTestCase {
@@ -47,7 +49,8 @@
   private PluginConfigFactory pluginConfigFactory;
   private Config serverConfig;
 
-  public void setupIsEnabled(String enabled, String parentEnabled, String[] branches) {
+  public void setupIsEnabled(
+      String enabled, String itsProject, String parentEnabled, String[] branches) {
     ProjectState projectState = createMock(ProjectState.class);
 
     expect(projectCache.get(new Project.NameKey("testProject"))).andReturn(projectState).anyTimes();
@@ -81,7 +84,7 @@
 
       parents = Arrays.asList(parentProjectState, projectState);
     }
-    expect(projectState.treeInOrder()).andReturn(parents);
+    expect(projectState.treeInOrder()).andReturn(parents).anyTimes();
 
     PluginConfig pluginConfig = createMock(PluginConfig.class);
 
@@ -90,6 +93,7 @@
         .anyTimes();
 
     expect(pluginConfig.getString("enabled", "false")).andReturn(enabled).anyTimes();
+    expect(pluginConfig.getString(eq("its-project"))).andReturn(itsProject).anyTimes();
 
     PluginConfig pluginConfigWI = createMock(PluginConfig.class);
 
@@ -104,7 +108,7 @@
 
   public void testIsEnabledRefNoParentNoBranchEnabled() {
     String[] branches = {};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -116,7 +120,7 @@
 
   public void BROKEN_testIsEnabledRefNoParentNoBranchDisabled() {
     String[] branches = {};
-    setupIsEnabled("false", null, branches);
+    setupIsEnabled("false", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -128,7 +132,7 @@
 
   public void testIsEnabledRefNoParentNoBranchEnforced() {
     String[] branches = {};
-    setupIsEnabled("enforced", null, branches);
+    setupIsEnabled("enforced", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -140,7 +144,7 @@
 
   public void testIsEnabledRefNoParentMatchingBranchEnabled() {
     String[] branches = {"^refs/heads/test.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -152,7 +156,7 @@
 
   public void BROKEN_testIsEnabledRefNoParentMatchingBranchDisabled() {
     String[] branches = {"^refs/heads/test.*"};
-    setupIsEnabled("false", null, branches);
+    setupIsEnabled("false", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -164,7 +168,7 @@
 
   public void testIsEnabledRefNoParentMatchingBranchEnforced() {
     String[] branches = {"^refs/heads/test.*"};
-    setupIsEnabled("enforced", null, branches);
+    setupIsEnabled("enforced", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -176,7 +180,7 @@
 
   public void BROKEN_testIsEnabledRefNoParentNonMatchingBranchEnabled() {
     String[] branches = {"^refs/heads/foo.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -188,7 +192,7 @@
 
   public void BROKEN_testIsEnabledRefNoParentNonMatchingBranchDisabled() {
     String[] branches = {"^refs/heads/foo.*"};
-    setupIsEnabled("false", null, branches);
+    setupIsEnabled("false", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -200,7 +204,7 @@
 
   public void BROKEN_testIsEnabledRefNoParentNonMatchingBranchEnforced() {
     String[] branches = {"^refs/heads/foo.*"};
-    setupIsEnabled("enforced", null, branches);
+    setupIsEnabled("enforced", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -212,7 +216,7 @@
 
   public void testIsEnabledRefNoParentMatchingBranchMiddleEnabled() {
     String[] branches = {"^refs/heads/foo.*", "^refs/heads/test.*", "^refs/heads/baz.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -224,7 +228,7 @@
 
   public void BROKEN_testIsEnabledRefNoParentMatchingBranchMiddleDisabled() {
     String[] branches = {"^refs/heads/foo.*", "^refs/heads/test.*", "^refs/heads/baz.*"};
-    setupIsEnabled("false", null, branches);
+    setupIsEnabled("false", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -236,7 +240,7 @@
 
   public void testIsEnabledRefNoParentMatchingBranchMiddleEnforced() {
     String[] branches = {"^refs/heads/foo.*", "^refs/heads/test.*", "^refs/heads/baz.*"};
-    setupIsEnabled("enforced", null, branches);
+    setupIsEnabled("enforced", null, null, branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -248,7 +252,7 @@
 
   public void BROKEN_testIsEnabledRefParentNoBranchEnabled() {
     String[] branches = {};
-    setupIsEnabled("false", "true", branches);
+    setupIsEnabled("false", null, "true", branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -260,7 +264,7 @@
 
   public void BROKEN_testIsEnabledRefParentNoBranchDisabled() {
     String[] branches = {};
-    setupIsEnabled("false", "false", branches);
+    setupIsEnabled("false", null, "false", branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -272,7 +276,7 @@
 
   public void testIsEnabledRefParentNoBranchEnforced() {
     String[] branches = {};
-    setupIsEnabled("false", "enforced", branches);
+    setupIsEnabled("false", null, "enforced", branches);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -284,7 +288,7 @@
 
   public void testIsEnabledEventNoBranches() {
     String[] branches = {};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -297,7 +301,7 @@
 
   public void testIsEnabledEventSingleBranchExact() {
     String[] branches = {"refs/heads/testBranch"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -310,7 +314,7 @@
 
   public void testIsEnabledEventSingleBranchRegExp() {
     String[] branches = {"^refs/heads/test.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -323,7 +327,7 @@
 
   public void BROKEN_testIsEnabledEventSingleBranchNonMatchingRegExp() {
     String[] branches = {"^refs/heads/foo.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -336,7 +340,7 @@
 
   public void testIsEnabledEventMultiBranchExact() {
     String[] branches = {"refs/heads/foo", "refs/heads/testBranch"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -349,7 +353,7 @@
 
   public void testIsEnabledEventMultiBranchRegExp() {
     String[] branches = {"^refs/heads/foo.*", "^refs/heads/test.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -362,7 +366,7 @@
 
   public void testIsEnabledEventMultiBranchMixedMatchExact() {
     String[] branches = {"refs/heads/testBranch", "refs/heads/foo.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -375,7 +379,7 @@
 
   public void testIsEnabledEventMultiBranchMixedMatchRegExp() {
     String[] branches = {"refs/heads/foo", "^refs/heads/test.*"};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -388,7 +392,7 @@
 
   public void BROKEN_testIsEnabledEventDisabled() {
     String[] branches = {"^refs/heads/testBranch"};
-    setupIsEnabled("false", null, branches);
+    setupIsEnabled("false", null, null, branches);
 
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
@@ -401,7 +405,7 @@
 
   public void testIsEnabledCommentAddedEvent() {
     String[] branches = {};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     CommentAddedEvent event = new CommentAddedEvent(testChange("testProject", "testBranch"));
 
@@ -414,7 +418,7 @@
 
   public void testIsEnabledChangeMergedEvent() {
     String[] branches = {};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     ChangeMergedEvent event = new ChangeMergedEvent(testChange("testProject", "testBranch"));
 
@@ -427,7 +431,7 @@
 
   public void testIsEnabledChangeAbandonedEvent() {
     String[] branches = {};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     ChangeAbandonedEvent event = new ChangeAbandonedEvent(testChange("testProject", "testBranch"));
 
@@ -440,7 +444,7 @@
 
   public void testIsEnabledChangeRestoredEvent() {
     String[] branches = {};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     ChangeRestoredEvent event = new ChangeRestoredEvent(testChange("testProject", "testBranch"));
 
@@ -453,7 +457,7 @@
 
   public void testIsEnabledRefUpdatedEvent() {
     String[] branches = {};
-    setupIsEnabled("true", null, branches);
+    setupIsEnabled("true", null, null, branches);
 
     RefUpdatedEvent event = new RefUpdatedEvent();
     RefUpdateAttribute refUpdateAttribute = new RefUpdateAttribute();
@@ -469,7 +473,7 @@
   }
 
   public void BROKEN_testIsEnabledUnknownEvent() {
-    Event event = new Event("foo") {};
+    RefEvent event = createMock(RefEvent.class);
 
     ItsConfig itsConfig = createItsConfig();
 
@@ -479,6 +483,31 @@
     assertLogMessageContains("not recognised and ignored");
   }
 
+  public void testGetItsProjectNull() {
+    String[] branches = {};
+    setupIsEnabled("true", null, null, branches);
+
+    ItsConfig itsConfig = createItsConfig();
+
+    replayMocks();
+
+    assertFalse(itsConfig.getItsProjectName(new Project.NameKey("testProject")).isPresent());
+  }
+
+  public void testGetItsProjectConfigured() {
+    String[] branches = {};
+    setupIsEnabled("true", "itsProject", null, branches);
+
+    ItsConfig itsConfig = createItsConfig();
+
+    replayMocks();
+
+    Optional<String> itsProjectName =
+        itsConfig.getItsProjectName(new Project.NameKey("testProject"));
+    assertTrue(itsProjectName.isPresent());
+    assertEquals("itsProject", itsProjectName.get());
+  }
+
   public void testGetIssuePatternNullMatch() {
     ItsConfig itsConfig = createItsConfig();
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/util/ItsProjectExtractorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/util/ItsProjectExtractorTest.java
new file mode 100644
index 0000000..0aecd05
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/util/ItsProjectExtractorTest.java
@@ -0,0 +1,62 @@
+// 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.its.base.util;
+
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
+import com.googlesource.gerrit.plugins.its.base.testutil.MockingTestCase;
+import java.util.Optional;
+
+public class ItsProjectExtractorTest extends MockingTestCase {
+
+  private static final String PROJECT = "project";
+  private static final String ITS_PROJECT = "itsProject";
+
+  private Injector injector;
+  private ItsConfig itsConfig;
+
+  public void test() {
+    ItsProjectExtractor projectExtractor = injector.getInstance(ItsProjectExtractor.class);
+
+    expect(itsConfig.getItsProjectName(new Project.NameKey(PROJECT)))
+        .andReturn(Optional.of(ITS_PROJECT))
+        .once();
+
+    replayMocks();
+
+    String ret = projectExtractor.getItsProject(PROJECT).orElse(null);
+    assertEquals(ret, ITS_PROJECT);
+  }
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      itsConfig = createMock(ItsConfig.class);
+      bind(ItsConfig.class).toInstance(itsConfig);
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractorTest.java
index 69d8196..3e61bc1 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractorTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyAttributeExtractorTest.java
@@ -15,9 +15,10 @@
 
 import static org.easymock.EasyMock.expect;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
@@ -28,23 +29,22 @@
 import com.google.inject.Injector;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import java.util.Set;
+import java.util.HashMap;
+import java.util.Map;
 
 public class PropertyAttributeExtractorTest extends LoggingMockingTestCase {
   private Injector injector;
 
   private ItsFacade facade;
-  private Property.Factory propertyFactory;
 
   public void testAccountAttributeNull() {
     replayMocks();
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(null, "prefix");
+    Map<String, String> actual = extractor.extractFrom(null, "prefix");
 
-    Set<Property> expected = Sets.newHashSet();
+    Map<String, String> expected = new HashMap<>();
 
     assertEquals("Properties do not match", expected, actual);
   }
@@ -55,39 +55,18 @@
     accountAttribute.name = "testName";
     accountAttribute.username = "testUsername";
 
-    // deprecated, to be removed soon. migrate to ones without dash.
-    Property propertyEmail2 = createMock(Property.class);
-    expect(propertyFactory.create("prefix-email", "testEmail")).andReturn(propertyEmail2);
-
-    Property propertyName2 = createMock(Property.class);
-    expect(propertyFactory.create("prefix-name", "testName")).andReturn(propertyName2);
-
-    Property propertyUsername2 = createMock(Property.class);
-    expect(propertyFactory.create("prefix-username", "testUsername")).andReturn(propertyUsername2);
-
-    // New style configs for vm and soy
-    Property propertyEmail = createMock(Property.class);
-    expect(propertyFactory.create("prefixEmail", "testEmail")).andReturn(propertyEmail);
-
-    Property propertyName = createMock(Property.class);
-    expect(propertyFactory.create("prefixName", "testName")).andReturn(propertyName);
-
-    Property propertyUsername = createMock(Property.class);
-    expect(propertyFactory.create("prefixUsername", "testUsername")).andReturn(propertyUsername);
-
     replayMocks();
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(accountAttribute, "prefix");
+    Map<String, String> actual = extractor.extractFrom(accountAttribute, "prefix");
 
-    Set<Property> expected = Sets.newHashSet();
-    expected.add(propertyEmail);
-    expected.add(propertyName);
-    expected.add(propertyUsername);
-    expected.add(propertyEmail2);
-    expected.add(propertyName2);
-    expected.add(propertyUsername2);
+    ImmutableMap<String, String> expected =
+        new ImmutableMap.Builder<String, String>()
+            .put("prefixEmail", "testEmail")
+            .put("prefixName", "testName")
+            .put("prefixUsername", "testUsername")
+            .build();
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -98,7 +77,6 @@
     owner.username = "testUsername";
 
     ChangeAttribute changeAttribute = new ChangeAttribute();
-    changeAttribute.project = "testProject";
     changeAttribute.branch = "testBranch";
     changeAttribute.topic = "testTopic";
     changeAttribute.subject = "testSubject";
@@ -107,75 +85,7 @@
     changeAttribute.url = "http://www.example.org/test";
     changeAttribute.owner = owner;
     changeAttribute.commitMessage = "Commit Message";
-
-    Property propertyProject = createMock(Property.class);
-    expect(propertyFactory.create("project", "testProject")).andReturn(propertyProject);
-
-    Property propertyBranch = createMock(Property.class);
-    expect(propertyFactory.create("branch", "testBranch")).andReturn(propertyBranch);
-
-    Property propertyTopic = createMock(Property.class);
-    expect(propertyFactory.create("topic", "testTopic")).andReturn(propertyTopic);
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertyFactory.create("subject", "testSubject")).andReturn(propertySubject);
-
-    Property propertyEscapedSubject = createMock(Property.class);
-    expect(propertyFactory.create("escapedSubject", "testSubject"))
-        .andReturn(propertyEscapedSubject);
-
-    Property propertyId2 = createMock(Property.class);
-    expect(propertyFactory.create("change-id", "testId")).andReturn(propertyId2);
-
-    Property propertyId = createMock(Property.class);
-    expect(propertyFactory.create("changeId", "testId")).andReturn(propertyId);
-
-    Property propertyNumber2 = createMock(Property.class);
-    expect(propertyFactory.create("change-number", "4711")).andReturn(propertyNumber2);
-
-    Property propertyNumber = createMock(Property.class);
-    expect(propertyFactory.create("changeNumber", "4711")).andReturn(propertyNumber);
-
-    Property propertyUrl2 = createMock(Property.class);
-    expect(propertyFactory.create("change-url", "http://www.example.org/test"))
-        .andReturn(propertyUrl2);
-
-    Property propertyUrl = createMock(Property.class);
-    expect(propertyFactory.create("changeUrl", "http://www.example.org/test"))
-        .andReturn(propertyUrl);
-
-    Property propertyStatus = createMock(Property.class);
-    expect(propertyFactory.create("status", null)).andReturn(propertyStatus);
-
-    Property propertyEmail = createMock(Property.class);
-    expect(propertyFactory.create("ownerEmail", "testEmail")).andReturn(propertyEmail);
-
-    Property propertyName = createMock(Property.class);
-    expect(propertyFactory.create("ownerName", "testName")).andReturn(propertyName);
-
-    Property propertyUsername = createMock(Property.class);
-    expect(propertyFactory.create("ownerUsername", "testUsername")).andReturn(propertyUsername);
-
-    Property propertyCommitMessage = createMock(Property.class);
-    expect(propertyFactory.create("commitMessage", "Commit Message"))
-        .andReturn(propertyCommitMessage);
-
-    Property propertyEmail2 = createMock(Property.class);
-    expect(propertyFactory.create("owner-email", "testEmail")).andReturn(propertyEmail2);
-
-    Property propertyName2 = createMock(Property.class);
-    expect(propertyFactory.create("owner-name", "testName")).andReturn(propertyName2);
-
-    Property propertyUsername2 = createMock(Property.class);
-    expect(propertyFactory.create("owner-username", "testUsername")).andReturn(propertyUsername2);
-
-    Property propertyCommitMessage2 = createMock(Property.class);
-    expect(propertyFactory.create("commit-message", "Commit Message"))
-        .andReturn(propertyCommitMessage2);
-
-    Property propertyFormatChangeUrl = createMock(Property.class);
-    expect(propertyFactory.create("formatChangeUrl", "http://www.example.org/test"))
-        .andReturn(propertyFormatChangeUrl);
+    changeAttribute.status = Change.Status.NEW;
 
     expect(facade.createLinkForWebui("http://www.example.org/test", "http://www.example.org/test"))
         .andReturn("http://www.example.org/test");
@@ -184,30 +94,71 @@
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(changeAttribute);
+    Map<String, String> actual = extractor.extractFrom(changeAttribute);
 
-    Set<Property> expected = Sets.newHashSet();
-    expected.add(propertyProject);
-    expected.add(propertyBranch);
-    expected.add(propertyTopic);
-    expected.add(propertySubject);
-    expected.add(propertyEscapedSubject);
-    expected.add(propertyId);
-    expected.add(propertyId2);
-    expected.add(propertyNumber);
-    expected.add(propertyNumber2);
-    expected.add(propertyUrl);
-    expected.add(propertyUrl2);
-    expected.add(propertyStatus);
-    expected.add(propertyEmail);
-    expected.add(propertyName);
-    expected.add(propertyUsername);
-    expected.add(propertyCommitMessage);
-    expected.add(propertyEmail2);
-    expected.add(propertyName2);
-    expected.add(propertyUsername2);
-    expected.add(propertyCommitMessage2);
-    expected.add(propertyFormatChangeUrl);
+    ImmutableMap<String, String> expected =
+        new ImmutableMap.Builder<String, String>()
+            .put("branch", "testBranch")
+            .put("topic", "testTopic")
+            .put("subject", "testSubject")
+            .put("escapedSubject", "testSubject")
+            .put("changeId", "testId")
+            .put("changeNumber", "4711")
+            .put("changeUrl", "http://www.example.org/test")
+            .put("status", Change.Status.NEW.name())
+            .put("ownerEmail", "testEmail")
+            .put("ownerName", "testName")
+            .put("ownerUsername", "testUsername")
+            .put("commitMessage", "Commit Message")
+            .put("formatChangeUrl", "http://www.example.org/test")
+            .put("private", "false")
+            .put("wip", "false")
+            .build();
+    assertEquals("Properties do not match", expected, actual);
+  }
+
+  public void testChangeAttributeNoOwnerEmail() {
+    AccountAttribute owner = new AccountAttribute();
+    owner.name = "testName";
+    owner.username = "testUsername";
+
+    ChangeAttribute changeAttribute = new ChangeAttribute();
+    changeAttribute.branch = "testBranch";
+    changeAttribute.topic = "testTopic";
+    changeAttribute.subject = "testSubject";
+    changeAttribute.id = "testId";
+    changeAttribute.number = 4711;
+    changeAttribute.url = "http://www.example.org/test";
+    changeAttribute.owner = owner;
+    changeAttribute.commitMessage = "Commit Message";
+    changeAttribute.status = Change.Status.NEW;
+
+    expect(facade.createLinkForWebui("http://www.example.org/test", "http://www.example.org/test"))
+        .andReturn("http://www.example.org/test");
+
+    replayMocks();
+
+    PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
+
+    Map<String, String> actual = extractor.extractFrom(changeAttribute);
+
+    ImmutableMap<String, String> expected =
+        new ImmutableMap.Builder<String, String>()
+            .put("branch", "testBranch")
+            .put("topic", "testTopic")
+            .put("subject", "testSubject")
+            .put("escapedSubject", "testSubject")
+            .put("changeId", "testId")
+            .put("changeNumber", "4711")
+            .put("changeUrl", "http://www.example.org/test")
+            .put("status", Change.Status.NEW.name())
+            .put("ownerName", "testName")
+            .put("ownerUsername", "testUsername")
+            .put("commitMessage", "Commit Message")
+            .put("formatChangeUrl", "http://www.example.org/test")
+            .put("private", "false")
+            .put("wip", "false")
+            .build();
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -218,7 +169,6 @@
     owner.username = "testUsername";
 
     ChangeAttribute changeAttribute = new ChangeAttribute();
-    changeAttribute.project = "testProject";
     changeAttribute.branch = "testBranch";
     changeAttribute.topic = "testTopic";
     changeAttribute.subject = "testSubject";
@@ -229,75 +179,6 @@
     changeAttribute.owner = owner;
     changeAttribute.commitMessage = "Commit Message";
 
-    Property propertyProject = createMock(Property.class);
-    expect(propertyFactory.create("project", "testProject")).andReturn(propertyProject);
-
-    Property propertyBranch = createMock(Property.class);
-    expect(propertyFactory.create("branch", "testBranch")).andReturn(propertyBranch);
-
-    Property propertyTopic = createMock(Property.class);
-    expect(propertyFactory.create("topic", "testTopic")).andReturn(propertyTopic);
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertyFactory.create("subject", "testSubject")).andReturn(propertySubject);
-
-    Property propertyEscapedSubject = createMock(Property.class);
-    expect(propertyFactory.create("escapedSubject", "testSubject"))
-        .andReturn(propertyEscapedSubject);
-
-    Property propertyId = createMock(Property.class);
-    expect(propertyFactory.create("changeId", "testId")).andReturn(propertyId);
-
-    Property propertyNumber = createMock(Property.class);
-    expect(propertyFactory.create("changeNumber", "4711")).andReturn(propertyNumber);
-
-    Property propertyUrl = createMock(Property.class);
-    expect(propertyFactory.create("changeUrl", "http://www.example.org/test"))
-        .andReturn(propertyUrl);
-
-    Property propertyId2 = createMock(Property.class);
-    expect(propertyFactory.create("change-id", "testId")).andReturn(propertyId2);
-
-    Property propertyNumber2 = createMock(Property.class);
-    expect(propertyFactory.create("change-number", "4711")).andReturn(propertyNumber2);
-
-    Property propertyUrl2 = createMock(Property.class);
-    expect(propertyFactory.create("change-url", "http://www.example.org/test"))
-        .andReturn(propertyUrl2);
-
-    Property propertyStatus = createMock(Property.class);
-    expect(propertyFactory.create("status", "ABANDONED")).andReturn(propertyStatus);
-
-    Property propertyEmail = createMock(Property.class);
-    expect(propertyFactory.create("ownerEmail", "testEmail")).andReturn(propertyEmail);
-
-    Property propertyName = createMock(Property.class);
-    expect(propertyFactory.create("ownerName", "testName")).andReturn(propertyName);
-
-    Property propertyUsername = createMock(Property.class);
-    expect(propertyFactory.create("ownerUsername", "testUsername")).andReturn(propertyUsername);
-
-    Property propertyCommitMessage = createMock(Property.class);
-    expect(propertyFactory.create("commitMessage", "Commit Message"))
-        .andReturn(propertyCommitMessage);
-
-    Property propertyEmail2 = createMock(Property.class);
-    expect(propertyFactory.create("owner-email", "testEmail")).andReturn(propertyEmail2);
-
-    Property propertyName2 = createMock(Property.class);
-    expect(propertyFactory.create("owner-name", "testName")).andReturn(propertyName2);
-
-    Property propertyUsername2 = createMock(Property.class);
-    expect(propertyFactory.create("owner-username", "testUsername")).andReturn(propertyUsername2);
-
-    Property propertyCommitMessage2 = createMock(Property.class);
-    expect(propertyFactory.create("commit-message", "Commit Message"))
-        .andReturn(propertyCommitMessage2);
-
-    Property propertyFormatChangeUrl = createMock(Property.class);
-    expect(propertyFactory.create("formatChangeUrl", "http://www.example.org/test"))
-        .andReturn(propertyFormatChangeUrl);
-
     expect(facade.createLinkForWebui("http://www.example.org/test", "http://www.example.org/test"))
         .andReturn("http://www.example.org/test");
 
@@ -305,30 +186,26 @@
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(changeAttribute);
+    Map<String, String> actual = extractor.extractFrom(changeAttribute);
 
-    Set<Property> expected = Sets.newHashSet();
-    expected.add(propertyProject);
-    expected.add(propertyBranch);
-    expected.add(propertyTopic);
-    expected.add(propertySubject);
-    expected.add(propertyEscapedSubject);
-    expected.add(propertyId);
-    expected.add(propertyNumber);
-    expected.add(propertyUrl);
-    expected.add(propertyId2);
-    expected.add(propertyNumber2);
-    expected.add(propertyUrl2);
-    expected.add(propertyStatus);
-    expected.add(propertyEmail);
-    expected.add(propertyName);
-    expected.add(propertyUsername);
-    expected.add(propertyCommitMessage);
-    expected.add(propertyEmail2);
-    expected.add(propertyName2);
-    expected.add(propertyUsername2);
-    expected.add(propertyCommitMessage2);
-    expected.add(propertyFormatChangeUrl);
+    ImmutableMap<String, String> expected =
+        new ImmutableMap.Builder<String, String>()
+            .put("branch", "testBranch")
+            .put("topic", "testTopic")
+            .put("subject", "testSubject")
+            .put("escapedSubject", "testSubject")
+            .put("changeId", "testId")
+            .put("changeNumber", "4711")
+            .put("changeUrl", "http://www.example.org/test")
+            .put("status", Change.Status.ABANDONED.name())
+            .put("ownerEmail", "testEmail")
+            .put("ownerName", "testName")
+            .put("ownerUsername", "testUsername")
+            .put("commitMessage", "Commit Message")
+            .put("formatChangeUrl", "http://www.example.org/test")
+            .put("private", "false")
+            .put("wip", "false")
+            .build();
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -354,103 +231,28 @@
     patchSetAttribute.uploader = uploader;
     patchSetAttribute.author = author;
 
-    Property propertyRevision = createMock(Property.class);
-    expect(propertyFactory.create("revision", "1234567891123456789212345678931234567894"))
-        .andReturn(propertyRevision);
-
-    Property propertyNumber = createMock(Property.class);
-    expect(propertyFactory.create("patchSetNumber", "42")).andReturn(propertyNumber);
-
-    Property propertyNumber2 = createMock(Property.class);
-    expect(propertyFactory.create("patch-set-number", "42")).andReturn(propertyNumber2);
-
-    Property propertyRef = createMock(Property.class);
-    expect(propertyFactory.create("ref", "testRef")).andReturn(propertyRef);
-
-    Property propertyCreatedOn = createMock(Property.class);
-    expect(propertyFactory.create("createdOn", "1234567890")).andReturn(propertyCreatedOn);
-
-    Property propertyCreatedOn2 = createMock(Property.class);
-    expect(propertyFactory.create("created-on", "1234567890")).andReturn(propertyCreatedOn2);
-
-    Property propertyParents = createMock(Property.class);
-    expect(propertyFactory.create("parents", "[parent1, parent2]")).andReturn(propertyParents);
-
-    Property propertyDeletions = createMock(Property.class);
-    expect(propertyFactory.create("deletions", "7")).andReturn(propertyDeletions);
-
-    Property propertyInsertions = createMock(Property.class);
-    expect(propertyFactory.create("insertions", "12")).andReturn(propertyInsertions);
-
-    Property propertyUploaderEmail = createMock(Property.class);
-    expect(propertyFactory.create("uploaderEmail", "testEmail1")).andReturn(propertyUploaderEmail);
-
-    Property propertyUploaderName = createMock(Property.class);
-    expect(propertyFactory.create("uploaderName", "testName1")).andReturn(propertyUploaderName);
-
-    Property propertyUploaderUsername = createMock(Property.class);
-    expect(propertyFactory.create("uploaderUsername", "testUsername1"))
-        .andReturn(propertyUploaderUsername);
-
-    Property propertyUploaderEmail2 = createMock(Property.class);
-    expect(propertyFactory.create("uploader-email", "testEmail1"))
-        .andReturn(propertyUploaderEmail2);
-
-    Property propertyUploaderName2 = createMock(Property.class);
-    expect(propertyFactory.create("uploader-name", "testName1")).andReturn(propertyUploaderName2);
-
-    Property propertyUploaderUsername2 = createMock(Property.class);
-    expect(propertyFactory.create("uploader-username", "testUsername1"))
-        .andReturn(propertyUploaderUsername2);
-
-    Property propertyAuthorEmail = createMock(Property.class);
-    expect(propertyFactory.create("authorEmail", "testEmail2")).andReturn(propertyAuthorEmail);
-
-    Property propertyAuthorName = createMock(Property.class);
-    expect(propertyFactory.create("authorName", "testName2")).andReturn(propertyAuthorName);
-
-    Property propertyAuthorUsername = createMock(Property.class);
-    expect(propertyFactory.create("authorUsername", "testUsername2"))
-        .andReturn(propertyAuthorUsername);
-
-    Property propertyAuthorEmail2 = createMock(Property.class);
-    expect(propertyFactory.create("author-email", "testEmail2")).andReturn(propertyAuthorEmail2);
-
-    Property propertyAuthorName2 = createMock(Property.class);
-    expect(propertyFactory.create("author-name", "testName2")).andReturn(propertyAuthorName2);
-
-    Property propertyAuthorUsername2 = createMock(Property.class);
-    expect(propertyFactory.create("author-username", "testUsername2"))
-        .andReturn(propertyAuthorUsername2);
-
     replayMocks();
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(patchSetAttribute);
+    Map<String, String> actual = extractor.extractFrom(patchSetAttribute);
 
-    Set<Property> expected = Sets.newHashSet();
-    expected.add(propertyRevision);
-    expected.add(propertyNumber);
-    expected.add(propertyNumber2);
-    expected.add(propertyRef);
-    expected.add(propertyCreatedOn);
-    expected.add(propertyCreatedOn2);
-    expected.add(propertyParents);
-    expected.add(propertyDeletions);
-    expected.add(propertyInsertions);
-    expected.add(propertyUploaderEmail);
-    expected.add(propertyUploaderName);
-    expected.add(propertyUploaderUsername);
-    expected.add(propertyUploaderEmail2);
-    expected.add(propertyUploaderName2);
-    expected.add(propertyUploaderUsername2);
-    expected.add(propertyAuthorEmail);
-    expected.add(propertyAuthorName);
-    expected.add(propertyAuthorUsername);
-    expected.add(propertyAuthorEmail2);
-    expected.add(propertyAuthorName2);
-    expected.add(propertyAuthorUsername2);
+    ImmutableMap<String, String> expected =
+        new ImmutableMap.Builder<String, String>()
+            .put("revision", "1234567891123456789212345678931234567894")
+            .put("patchSetNumber", "42")
+            .put("ref", "testRef")
+            .put("createdOn", "1234567890")
+            .put("parents", "[parent1, parent2]")
+            .put("deletions", "7")
+            .put("insertions", "12")
+            .put("uploaderEmail", "testEmail1")
+            .put("uploaderName", "testName1")
+            .put("uploaderUsername", "testUsername1")
+            .put("authorEmail", "testEmail2")
+            .put("authorName", "testName2")
+            .put("authorUsername", "testUsername2")
+            .build();
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -458,39 +260,20 @@
     RefUpdateAttribute refUpdateAttribute = new RefUpdateAttribute();
     refUpdateAttribute.newRev = "1234567891123456789212345678931234567894";
     refUpdateAttribute.oldRev = "9876543211987654321298765432139876543214";
-    refUpdateAttribute.project = "testProject";
     refUpdateAttribute.refName = "testRef";
 
-    Property propertyRevision = createMock(Property.class);
-    expect(propertyFactory.create("revision", "1234567891123456789212345678931234567894"))
-        .andReturn(propertyRevision);
-
-    Property propertyRevisionOld = createMock(Property.class);
-    expect(propertyFactory.create("revisionOld", "9876543211987654321298765432139876543214"))
-        .andReturn(propertyRevisionOld);
-
-    Property propertyRevisionOld2 = createMock(Property.class);
-    expect(propertyFactory.create("revision-old", "9876543211987654321298765432139876543214"))
-        .andReturn(propertyRevisionOld2);
-
-    Property propertyProject = createMock(Property.class);
-    expect(propertyFactory.create("project", "testProject")).andReturn(propertyProject);
-
-    Property propertyRef = createMock(Property.class);
-    expect(propertyFactory.create("ref", "testRef")).andReturn(propertyRef);
-
     replayMocks();
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(refUpdateAttribute);
+    Map<String, String> actual = extractor.extractFrom(refUpdateAttribute);
 
-    Set<Property> expected = Sets.newHashSet();
-    expected.add(propertyRevision);
-    expected.add(propertyRevisionOld);
-    expected.add(propertyRevisionOld2);
-    expected.add(propertyProject);
-    expected.add(propertyRef);
+    ImmutableMap<String, String> expected =
+        new ImmutableMap.Builder<String, String>()
+            .put("revision", "1234567891123456789212345678931234567894")
+            .put("revisionOld", "9876543211987654321298765432139876543214")
+            .put("ref", "testRef")
+            .build();
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -499,21 +282,13 @@
     approvalAttribute.type = "TestType";
     approvalAttribute.value = "TestValue";
 
-    Property propertyApproval = createMock(Property.class);
-    expect(propertyFactory.create("approvalTestType", "TestValue")).andReturn(propertyApproval);
-
-    Property propertyApproval2 = createMock(Property.class);
-    expect(propertyFactory.create("approval-TestType", "TestValue")).andReturn(propertyApproval2);
-
     replayMocks();
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(approvalAttribute);
+    Map<String, String> actual = extractor.extractFrom(approvalAttribute);
 
-    Set<Property> expected = Sets.newHashSet();
-    expected.add(propertyApproval);
-    expected.add(propertyApproval2);
+    Map<String, String> expected = ImmutableMap.of("approvalTestType", "TestValue");
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -522,21 +297,13 @@
     approvalAttribute.type = "Test-Type";
     approvalAttribute.value = "TestValue";
 
-    Property propertyApproval = createMock(Property.class);
-    expect(propertyFactory.create("approvalTestType", "TestValue")).andReturn(propertyApproval);
-
-    Property propertyApproval2 = createMock(Property.class);
-    expect(propertyFactory.create("approval-Test-Type", "TestValue")).andReturn(propertyApproval2);
-
     replayMocks();
 
     PropertyAttributeExtractor extractor = injector.getInstance(PropertyAttributeExtractor.class);
 
-    Set<Property> actual = extractor.extractFrom(approvalAttribute);
+    Map<String, String> actual = extractor.extractFrom(approvalAttribute);
 
-    Set<Property> expected = Sets.newHashSet();
-    expected.add(propertyApproval);
-    expected.add(propertyApproval2);
+    Map<String, String> expected = ImmutableMap.of("approvalTestType", "TestValue");
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -551,8 +318,6 @@
     protected void configure() {
       facade = createMock(ItsFacade.class);
       bind(ItsFacade.class).toInstance(facade);
-      propertyFactory = createMock(Property.Factory.class);
-      bind(Property.Factory.class).toInstance(propertyFactory);
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractorTest.java
index 0b900c0..33e8aa6 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractorTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/util/PropertyExtractorTest.java
@@ -16,12 +16,15 @@
 import static org.easymock.EasyMock.expect;
 
 import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
@@ -31,77 +34,76 @@
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.ChangeRestoredEvent;
 import com.google.gerrit.server.events.CommentAddedEvent;
-import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.PatchSetCreatedEvent;
+import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.events.RefUpdatedEvent;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 public class PropertyExtractorTest extends LoggingMockingTestCase {
   private Injector injector;
 
+  private ItsProjectExtractor itsProjectExtractor;
   private IssueExtractor issueExtractor;
-  private Property.Factory propertyFactory;
   private PropertyAttributeExtractor propertyAttributeExtractor;
 
   public void testDummyChangeEvent() {
     PropertyExtractor propertyExtractor = injector.getInstance(PropertyExtractor.class);
 
-    Property property1 = createMock(Property.class);
-    expect(
-            propertyFactory.create(
-                "event",
-                "com.googlesource.gerrit.plugins."
-                    + "its.base.util.PropertyExtractorTest$DummyEvent"))
-        .andReturn(property1);
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
 
     replayMocks();
 
-    Set<Set<Property>> actual = propertyExtractor.extractFrom(new DummyEvent());
+    Set<Map<String, String>> actual =
+        propertyExtractor.extractFrom(new DummyEvent()).getIssuesProperties();
 
-    Set<Set<Property>> expected = Sets.newHashSet();
+    Set<Map<String, String>> expected = new HashSet<>();
     assertEquals("Properties do not match", expected, actual);
   }
 
   public void testChangeAbandonedEvent() {
     ChangeAbandonedEvent event = new ChangeAbandonedEvent(testChange("testProject", "testBranch"));
 
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
+
     ChangeAttribute changeAttribute = createMock(ChangeAttribute.class);
     event.change = Suppliers.ofInstance(changeAttribute);
-    Property propertyChange = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(changeAttribute))
-        .andReturn(Sets.newHashSet(propertyChange));
+    Map<String, String> changeProperties =
+        ImmutableMap.of("project", "testProject", "changeNumber", "176");
+    expect(propertyAttributeExtractor.extractFrom(changeAttribute)).andReturn(changeProperties);
 
     AccountAttribute accountAttribute = createMock(AccountAttribute.class);
     event.abandoner = Suppliers.ofInstance(accountAttribute);
-    Property propertySubmitter = createMock(Property.class);
+    Map<String, String> accountProperties = ImmutableMap.of("abandonerName", "testName");
     expect(propertyAttributeExtractor.extractFrom(accountAttribute, "abandoner"))
-        .andReturn(Sets.newHashSet(propertySubmitter));
+        .andReturn(accountProperties);
 
     PatchSetAttribute patchSetAttribute = createMock(PatchSetAttribute.class);
     event.patchSet = Suppliers.ofInstance(patchSetAttribute);
-    Property propertyPatchSet = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute))
-        .andReturn(Sets.newHashSet(propertyPatchSet));
+    Map<String, String> patchSetProperties =
+        ImmutableMap.of("revision", "testRevision", "patchSetNumber", "3");
+    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute)).andReturn(patchSetProperties);
 
     event.reason = "testReason";
-    Property propertyReason = createMock(Property.class);
-    expect(propertyFactory.create("reason", "testReason")).andReturn(propertyReason);
-
     changeAttribute.project = "testProject";
     changeAttribute.number = 176;
     patchSetAttribute.revision = "testRevision";
     patchSetAttribute.number = 3;
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertyChange);
-    common.add(propertySubmitter);
-    common.add(propertyPatchSet);
-    common.add(propertyReason);
+    Map<String, String> common =
+        ImmutableMap.<String, String>builder()
+            .putAll(changeProperties)
+            .putAll(accountProperties)
+            .putAll(patchSetProperties)
+            .put("reason", "testReason")
+            .put("ref", "refs/heads/testBranch")
+            .build();
 
     eventHelper(event, "ChangeAbandonedEvent", "change-abandoned", common, true);
   }
@@ -109,33 +111,38 @@
   public void testChangeMergedEvent() {
     ChangeMergedEvent event = new ChangeMergedEvent(testChange("testProject", "testBranch"));
 
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
+
     ChangeAttribute changeAttribute = createMock(ChangeAttribute.class);
     event.change = Suppliers.ofInstance(changeAttribute);
-    Property propertyChange = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(changeAttribute))
-        .andReturn(Sets.newHashSet(propertyChange));
+    Map<String, String> changeProperties =
+        ImmutableMap.of("project", "testProject", "changeNumber", "176");
+    expect(propertyAttributeExtractor.extractFrom(changeAttribute)).andReturn(changeProperties);
 
     AccountAttribute accountAttribute = createMock(AccountAttribute.class);
     event.submitter = Suppliers.ofInstance(accountAttribute);
-    Property propertySubmitter = createMock(Property.class);
+    Map<String, String> accountProperties = ImmutableMap.of("submitterName", "testName");
     expect(propertyAttributeExtractor.extractFrom(accountAttribute, "submitter"))
-        .andReturn(Sets.newHashSet(propertySubmitter));
+        .andReturn(accountProperties);
 
     PatchSetAttribute patchSetAttribute = createMock(PatchSetAttribute.class);
     event.patchSet = Suppliers.ofInstance(patchSetAttribute);
-    Property propertyPatchSet = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute))
-        .andReturn(Sets.newHashSet(propertyPatchSet));
+    Map<String, String> patchSetProperties =
+        ImmutableMap.of("revision", "testRevision", "patchSetNumber", "3");
+    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute)).andReturn(patchSetProperties);
 
     changeAttribute.project = "testProject";
     changeAttribute.number = 176;
     patchSetAttribute.revision = "testRevision";
     patchSetAttribute.number = 3;
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertyChange);
-    common.add(propertySubmitter);
-    common.add(propertyPatchSet);
+    Map<String, String> common =
+        ImmutableMap.<String, String>builder()
+            .putAll(changeProperties)
+            .putAll(accountProperties)
+            .putAll(patchSetProperties)
+            .put("ref", "refs/heads/testBranch")
+            .build();
 
     eventHelper(event, "ChangeMergedEvent", "change-merged", common, true);
   }
@@ -143,38 +150,40 @@
   public void testChangeRestoredEvent() {
     ChangeRestoredEvent event = new ChangeRestoredEvent(testChange("testProject", "testBranch"));
 
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
+
     ChangeAttribute changeAttribute = createMock(ChangeAttribute.class);
     event.change = Suppliers.ofInstance(changeAttribute);
-    Property propertyChange = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(changeAttribute))
-        .andReturn(Sets.newHashSet(propertyChange));
+    Map<String, String> changeProperties =
+        ImmutableMap.of("project", "testProject", "changeNumber", "176");
+    expect(propertyAttributeExtractor.extractFrom(changeAttribute)).andReturn(changeProperties);
 
     AccountAttribute accountAttribute = createMock(AccountAttribute.class);
     event.restorer = Suppliers.ofInstance(accountAttribute);
-    Property propertySubmitter = createMock(Property.class);
+    Map<String, String> accountProperties = ImmutableMap.of("restorerName", "testName");
     expect(propertyAttributeExtractor.extractFrom(accountAttribute, "restorer"))
-        .andReturn(Sets.newHashSet(propertySubmitter));
+        .andReturn(accountProperties);
 
     PatchSetAttribute patchSetAttribute = createMock(PatchSetAttribute.class);
     event.patchSet = Suppliers.ofInstance(patchSetAttribute);
-    Property propertyPatchSet = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute))
-        .andReturn(Sets.newHashSet(propertyPatchSet));
+    Map<String, String> patchSetProperties =
+        ImmutableMap.of("revision", "testRevision", "patchSetNumber", "3");
+    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute)).andReturn(patchSetProperties);
 
     event.reason = "testReason";
-    Property propertyReason = createMock(Property.class);
-    expect(propertyFactory.create("reason", "testReason")).andReturn(propertyReason);
-
     changeAttribute.project = "testProject";
     changeAttribute.number = 176;
     patchSetAttribute.revision = "testRevision";
     patchSetAttribute.number = 3;
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertyChange);
-    common.add(propertySubmitter);
-    common.add(propertyPatchSet);
-    common.add(propertyReason);
+    Map<String, String> common =
+        ImmutableMap.<String, String>builder()
+            .putAll(changeProperties)
+            .putAll(accountProperties)
+            .putAll(patchSetProperties)
+            .put("reason", "testReason")
+            .put("ref", "refs/heads/testBranch")
+            .build();
 
     eventHelper(event, "ChangeRestoredEvent", "change-restored", common, true);
   }
@@ -182,38 +191,40 @@
   public void testCommentAddedEventWOApprovals() {
     CommentAddedEvent event = new CommentAddedEvent(testChange("testProject", "testBranch"));
 
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
+
     ChangeAttribute changeAttribute = createMock(ChangeAttribute.class);
     event.change = Suppliers.ofInstance(changeAttribute);
-    Property propertyChange = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(changeAttribute))
-        .andReturn(Sets.newHashSet(propertyChange));
+    Map<String, String> changeProperties =
+        ImmutableMap.of("project", "testProject", "changeNumber", "176");
+    expect(propertyAttributeExtractor.extractFrom(changeAttribute)).andReturn(changeProperties);
 
     AccountAttribute accountAttribute = createMock(AccountAttribute.class);
     event.author = Suppliers.ofInstance(accountAttribute);
-    Property propertySubmitter = createMock(Property.class);
+    Map<String, String> accountProperties = ImmutableMap.of("commenterName", "testName");
     expect(propertyAttributeExtractor.extractFrom(accountAttribute, "commenter"))
-        .andReturn(Sets.newHashSet(propertySubmitter));
+        .andReturn(accountProperties);
 
     PatchSetAttribute patchSetAttribute = createMock(PatchSetAttribute.class);
     event.patchSet = Suppliers.ofInstance(patchSetAttribute);
-    Property propertyPatchSet = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute))
-        .andReturn(Sets.newHashSet(propertyPatchSet));
+    Map<String, String> patchSetProperties =
+        ImmutableMap.of("revision", "testRevision", "patchSetNumber", "3");
+    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute)).andReturn(patchSetProperties);
 
     event.comment = "testComment";
-    Property propertyComment = createMock(Property.class);
-    expect(propertyFactory.create("comment", "testComment")).andReturn(propertyComment);
-
     changeAttribute.project = "testProject";
     changeAttribute.number = 176;
     patchSetAttribute.revision = "testRevision";
     patchSetAttribute.number = 3;
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertyChange);
-    common.add(propertySubmitter);
-    common.add(propertyPatchSet);
-    common.add(propertyComment);
+    Map<String, String> common =
+        ImmutableMap.<String, String>builder()
+            .putAll(changeProperties)
+            .putAll(accountProperties)
+            .putAll(patchSetProperties)
+            .put("ref", "refs/heads/testBranch")
+            .put("comment", "testComment")
+            .build();
 
     eventHelper(event, "CommentAddedEvent", "comment-added", common, true);
   }
@@ -221,51 +232,53 @@
   public void testCommentAddedEventWApprovals() {
     CommentAddedEvent event = new CommentAddedEvent(testChange("testProject", "testBranch"));
 
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
+
     ChangeAttribute changeAttribute = createMock(ChangeAttribute.class);
     event.change = Suppliers.ofInstance(changeAttribute);
-    Property propertyChange = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(changeAttribute))
-        .andReturn(Sets.newHashSet(propertyChange));
+    Map<String, String> changeProperties =
+        ImmutableMap.of("project", "testProject", "changeNumber", "176");
+    expect(propertyAttributeExtractor.extractFrom(changeAttribute)).andReturn(changeProperties);
 
     AccountAttribute accountAttribute = createMock(AccountAttribute.class);
     event.author = Suppliers.ofInstance(accountAttribute);
-    Property propertySubmitter = createMock(Property.class);
+    Map<String, String> accountProperties = ImmutableMap.of("commenterName", "testName");
     expect(propertyAttributeExtractor.extractFrom(accountAttribute, "commenter"))
-        .andReturn(Sets.newHashSet(propertySubmitter));
+        .andReturn(accountProperties);
 
     PatchSetAttribute patchSetAttribute = createMock(PatchSetAttribute.class);
     event.patchSet = Suppliers.ofInstance(patchSetAttribute);
-    Property propertyPatchSet = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute))
-        .andReturn(Sets.newHashSet(propertyPatchSet));
+    Map<String, String> patchSetProperties =
+        ImmutableMap.of("revision", "testRevision", "patchSetNumber", "3");
+    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute)).andReturn(patchSetProperties);
 
     ApprovalAttribute approvalAttribute1 = createMock(ApprovalAttribute.class);
-    Property propertyApproval1 = createMock(Property.class);
+    Map<String, String> approuvalProperties1 = ImmutableMap.of("approvalCodeReview", "0");
     expect(propertyAttributeExtractor.extractFrom(approvalAttribute1))
-        .andReturn(Sets.newHashSet(propertyApproval1));
+        .andReturn(approuvalProperties1);
     ApprovalAttribute approvalAttribute2 = createMock(ApprovalAttribute.class);
-    Property propertyApproval2 = createMock(Property.class);
+    Map<String, String> approuvalProperties2 = ImmutableMap.of("approvalVerified", "0");
     expect(propertyAttributeExtractor.extractFrom(approvalAttribute2))
-        .andReturn(Sets.newHashSet(propertyApproval2));
+        .andReturn(approuvalProperties2);
     ApprovalAttribute[] approvalAttributes = {approvalAttribute1, approvalAttribute2};
     event.approvals = Suppliers.ofInstance(approvalAttributes);
 
     event.comment = "testComment";
-    Property propertyComment = createMock(Property.class);
-    expect(propertyFactory.create("comment", "testComment")).andReturn(propertyComment);
-
     changeAttribute.project = "testProject";
     changeAttribute.number = 176;
     patchSetAttribute.revision = "testRevision";
     patchSetAttribute.number = 3;
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertyChange);
-    common.add(propertySubmitter);
-    common.add(propertyPatchSet);
-    common.add(propertyComment);
-    common.add(propertyApproval1);
-    common.add(propertyApproval2);
+    Map<String, String> common =
+        ImmutableMap.<String, String>builder()
+            .putAll(changeProperties)
+            .putAll(accountProperties)
+            .putAll(patchSetProperties)
+            .putAll(approuvalProperties1)
+            .putAll(approuvalProperties2)
+            .put("ref", "refs/heads/testBranch")
+            .put("comment", "testComment")
+            .build();
 
     eventHelper(event, "CommentAddedEvent", "comment-added", common, true);
   }
@@ -273,33 +286,38 @@
   public void testPatchSetCreatedEvent() {
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(testChange("testProject", "testBranch"));
 
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
+
     ChangeAttribute changeAttribute = createMock(ChangeAttribute.class);
     event.change = Suppliers.ofInstance(changeAttribute);
-    Property propertyChange = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(changeAttribute))
-        .andReturn(Sets.newHashSet(propertyChange));
+    Map<String, String> changeProperties =
+        ImmutableMap.of("project", "testProject", "changeNumber", "176");
+    expect(propertyAttributeExtractor.extractFrom(changeAttribute)).andReturn(changeProperties);
 
     AccountAttribute accountAttribute = createMock(AccountAttribute.class);
     event.uploader = Suppliers.ofInstance(accountAttribute);
-    Property propertySubmitter = createMock(Property.class);
+    Map<String, String> accountProperties = ImmutableMap.of("uploaderName", "testName");
     expect(propertyAttributeExtractor.extractFrom(accountAttribute, "uploader"))
-        .andReturn(Sets.newHashSet(propertySubmitter));
+        .andReturn(accountProperties);
 
     PatchSetAttribute patchSetAttribute = createMock(PatchSetAttribute.class);
     event.patchSet = Suppliers.ofInstance(patchSetAttribute);
-    Property propertyPatchSet = createMock(Property.class);
-    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute))
-        .andReturn(Sets.newHashSet(propertyPatchSet));
+    Map<String, String> patchSetProperties =
+        ImmutableMap.of("revision", "testRevision", "patchSetNumber", "3");
+    expect(propertyAttributeExtractor.extractFrom(patchSetAttribute)).andReturn(patchSetProperties);
 
     changeAttribute.project = "testProject";
     changeAttribute.number = 176;
     patchSetAttribute.revision = "testRevision";
     patchSetAttribute.number = 3;
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertyChange);
-    common.add(propertySubmitter);
-    common.add(propertyPatchSet);
+    Map<String, String> common =
+        ImmutableMap.<String, String>builder()
+            .putAll(changeProperties)
+            .putAll(accountProperties)
+            .putAll(patchSetProperties)
+            .put("ref", "refs/heads/testBranch")
+            .build();
 
     eventHelper(event, "PatchSetCreatedEvent", "patchset-created", common, true);
   }
@@ -309,57 +327,43 @@
 
     AccountAttribute accountAttribute = createMock(AccountAttribute.class);
     event.submitter = Suppliers.ofInstance(accountAttribute);
-    Property propertySubmitter = createMock(Property.class);
+    Map<String, String> accountProperties = ImmutableMap.of("submitterName", "testName");
     expect(propertyAttributeExtractor.extractFrom(accountAttribute, "submitter"))
-        .andReturn(Sets.newHashSet(propertySubmitter));
+        .andReturn(accountProperties);
 
     RefUpdateAttribute refUpdateAttribute = createMock(RefUpdateAttribute.class);
     event.refUpdate = Suppliers.ofInstance(refUpdateAttribute);
-    Property propertyRefUpdated = createMock(Property.class);
+    Map<String, String> refUpdatedProperties =
+        ImmutableMap.of("revision", "testRevision", "revisionOld", "oldRevision");
     expect(propertyAttributeExtractor.extractFrom(refUpdateAttribute))
-        .andReturn(Sets.newHashSet(propertyRefUpdated));
+        .andReturn(refUpdatedProperties);
 
     refUpdateAttribute.project = "testProject";
     refUpdateAttribute.newRev = "testRevision";
+    refUpdateAttribute.oldRev = "oldRevision";
+    refUpdateAttribute.refName = "testBranch";
 
-    Set<Property> common = Sets.newHashSet();
-    common.add(propertySubmitter);
-    common.add(propertyRefUpdated);
+    expect(itsProjectExtractor.getItsProject("testProject")).andReturn(Optional.empty());
+
+    Map<String, String> common =
+        ImmutableMap.<String, String>builder()
+            .putAll(accountProperties)
+            .putAll(refUpdatedProperties)
+            .put("ref", "testBranch")
+            .put("project", "testProject")
+            .build();
 
     eventHelper(event, "RefUpdatedEvent", "ref-updated", common, false);
   }
 
   private void eventHelper(
-      Event event, String className, String type, Set<Property> common, boolean withRevision) {
+      RefEvent event,
+      String className,
+      String type,
+      Map<String, String> common,
+      boolean withRevision) {
     PropertyExtractor propertyExtractor = injector.getInstance(PropertyExtractor.class);
 
-    Property propertyItsName = createMock(Property.class);
-    expect(propertyFactory.create("its-name", "ItsTestName")).andReturn(propertyItsName).anyTimes();
-
-    Property propertyEvent = createMock(Property.class);
-    expect(propertyFactory.create("event", "com.google.gerrit.server.events." + className))
-        .andReturn(propertyEvent);
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyFactory.create("event-type", type)).andReturn(propertyEventType);
-
-    Property propertyAssociationFooter = createMock(Property.class);
-    expect(propertyFactory.create("association", "footer")).andReturn(propertyAssociationFooter);
-
-    Property propertyAssociationAnywhere = createMock(Property.class);
-    expect(propertyFactory.create("association", "anywhere"))
-        .andReturn(propertyAssociationAnywhere)
-        .times(2);
-
-    Property propertyAssociationBody = createMock(Property.class);
-    expect(propertyFactory.create("association", "body")).andReturn(propertyAssociationBody);
-
-    Property propertyIssue42 = createMock(Property.class);
-    expect(propertyFactory.create("issue", "42")).andReturn(propertyIssue42);
-
-    Property propertyIssue4711 = createMock(Property.class);
-    expect(propertyFactory.create("issue", "4711")).andReturn(propertyIssue4711);
-
     HashMap<String, Set<String>> issueMap = Maps.newHashMap();
     issueMap.put("4711", Sets.newHashSet("body", "anywhere"));
     issueMap.put("42", Sets.newHashSet("footer", "anywhere"));
@@ -373,28 +377,30 @@
 
     replayMocks();
 
-    Set<Set<Property>> actual = propertyExtractor.extractFrom(event);
+    Set<Map<String, String>> actual = propertyExtractor.extractFrom(event).getIssuesProperties();
 
-    Set<Set<Property>> expected = Sets.newHashSet();
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(propertyItsName);
-    properties.add(propertyEvent);
-    properties.add(propertyEventType);
-    properties.add(propertyAssociationAnywhere);
-    properties.add(propertyAssociationFooter);
-    properties.add(propertyIssue42);
-    properties.addAll(common);
-    expected.add(properties);
+    Map<String, String> propertiesIssue4711 =
+        ImmutableMap.<String, String>builder()
+            .put("itsName", "ItsTestName")
+            .put("event", "com.google.gerrit.server.events." + className)
+            .put("event-type", type)
+            .put("association", "body anywhere")
+            .put("issue", "4711")
+            .putAll(common)
+            .build();
+    Map<String, String> propertiesIssue42 =
+        ImmutableMap.<String, String>builder()
+            .put("itsName", "ItsTestName")
+            .put("event", "com.google.gerrit.server.events." + className)
+            .put("event-type", type)
+            .put("association", "anywhere footer")
+            .put("issue", "42")
+            .putAll(common)
+            .build();
+    Set<Map<String, String>> expected = new HashSet<>();
+    expected.add(propertiesIssue4711);
+    expected.add(propertiesIssue42);
 
-    properties = Sets.newHashSet();
-    properties.add(propertyItsName);
-    properties.add(propertyEvent);
-    properties.add(propertyEventType);
-    properties.add(propertyAssociationAnywhere);
-    properties.add(propertyAssociationBody);
-    properties.add(propertyIssue4711);
-    properties.addAll(common);
-    expected.add(properties);
     assertEquals("Properties do not match", expected, actual);
   }
 
@@ -409,20 +415,30 @@
     protected void configure() {
       bind(String.class).annotatedWith(PluginName.class).toInstance("ItsTestName");
 
+      itsProjectExtractor = createMock(ItsProjectExtractor.class);
+      bind(ItsProjectExtractor.class).toInstance(itsProjectExtractor);
+
       issueExtractor = createMock(IssueExtractor.class);
       bind(IssueExtractor.class).toInstance(issueExtractor);
 
       propertyAttributeExtractor = createMock(PropertyAttributeExtractor.class);
       bind(PropertyAttributeExtractor.class).toInstance(propertyAttributeExtractor);
-
-      propertyFactory = createMock(Property.Factory.class);
-      bind(Property.Factory.class).toInstance(propertyFactory);
     }
   }
 
-  private class DummyEvent extends Event {
+  private class DummyEvent extends RefEvent {
     public DummyEvent() {
       super(null);
     }
+
+    @Override
+    public String getRefName() {
+      return null;
+    }
+
+    @Override
+    public NameKey getProjectNameKey() {
+      return new Project.NameKey("testProject");
+    }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateCommentTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateCommentTest.java
index 5573878..d247a7c 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateCommentTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/validation/ItsValidateCommentTest.java
@@ -26,10 +26,12 @@
 import com.google.inject.Injector;
 import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacadeFactory;
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
 import com.googlesource.gerrit.plugins.its.base.util.IssueExtractor;
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -44,6 +46,7 @@
   private IssueExtractor issueExtractor;
   private ItsFacade itsFacade;
   private ItsConfig itsConfig;
+  private ItsFacadeFactory itsFacadeFactory;
 
   private Project project = new Project(new Project.NameKey("myProject"));
 
@@ -75,6 +78,7 @@
     expect(itsConfig.getItsAssociationPolicy())
         .andReturn(ItsAssociationPolicy.SUGGESTED)
         .atLeastOnce();
+    expect(itsConfig.getDummyIssuePattern()).andReturn(Optional.empty()).atLeastOnce();
     expect(commit.getFullMessage()).andReturn("TestMessage").atLeastOnce();
     expect(commit.getId()).andReturn(commit).anyTimes();
     expect(commit.getName()).andReturn("TestCommit").anyTimes();
@@ -99,6 +103,7 @@
     expect(itsConfig.getItsAssociationPolicy())
         .andReturn(ItsAssociationPolicy.MANDATORY)
         .atLeastOnce();
+    expect(itsConfig.getDummyIssuePattern()).andReturn(Optional.empty()).atLeastOnce();
     expect(commit.getFullMessage()).andReturn("TestMessage").atLeastOnce();
     expect(commit.getId()).andReturn(commit).anyTimes();
     expect(commit.getName()).andReturn("TestCommit").anyTimes();
@@ -116,6 +121,31 @@
     }
   }
 
+  public void testOnlySkipMatching() throws CommitValidationException {
+    List<CommitValidationMessage> ret;
+    ItsValidateComment ivc = injector.getInstance(ItsValidateComment.class);
+    ReceiveCommand command = createMock(ReceiveCommand.class);
+    RevCommit commit = createMock(RevCommit.class);
+    CommitReceivedEvent event = newCommitReceivedEvent(command, project, null, commit, null);
+
+    expect(itsConfig.getItsAssociationPolicy())
+        .andReturn(ItsAssociationPolicy.MANDATORY)
+        .atLeastOnce();
+    expect(itsConfig.getDummyIssuePattern())
+        .andReturn(Optional.of(Pattern.compile("SKIP")))
+        .atLeastOnce();
+    expect(commit.getFullMessage()).andReturn("TestMessage SKIP").atLeastOnce();
+    expect(commit.getId()).andReturn(commit).anyTimes();
+    expect(commit.getName()).andReturn("TestCommit").anyTimes();
+    expect(issueExtractor.getIssueIds("TestMessage SKIP")).andReturn(new String[] {}).atLeastOnce();
+
+    replayMocks();
+
+    ret = ivc.onCommitReceived(event);
+
+    assertEmptyList(ret);
+  }
+
   public void testSuggestedMatchingSingleExisting() throws CommitValidationException, IOException {
     List<CommitValidationMessage> ret;
     ItsValidateComment ivc = injector.getInstance(ItsValidateComment.class);
@@ -129,6 +159,7 @@
     expect(commit.getId()).andReturn(commit).anyTimes();
     expect(commit.getName()).andReturn("TestCommit").anyTimes();
     expect(issueExtractor.getIssueIds("bug#4711")).andReturn(new String[] {"4711"}).atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade);
     expect(itsFacade.exists("4711")).andReturn(true).atLeastOnce();
 
     replayMocks();
@@ -152,6 +183,7 @@
     expect(commit.getId()).andReturn(commit).anyTimes();
     expect(commit.getName()).andReturn("TestCommit").anyTimes();
     expect(issueExtractor.getIssueIds("bug#4711")).andReturn(new String[] {"4711"}).atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade);
     expect(itsFacade.exists("4711")).andReturn(true).atLeastOnce();
 
     replayMocks();
@@ -176,6 +208,7 @@
     expect(commit.getId()).andReturn(commit).anyTimes();
     expect(commit.getName()).andReturn("TestCommit").anyTimes();
     expect(issueExtractor.getIssueIds("bug#4711")).andReturn(new String[] {"4711"}).atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade);
     expect(itsFacade.exists("4711")).andReturn(false).atLeastOnce();
 
     replayMocks();
@@ -204,6 +237,7 @@
     expect(commit.getId()).andReturn(commit).anyTimes();
     expect(commit.getName()).andReturn("TestCommit").anyTimes();
     expect(issueExtractor.getIssueIds("bug#4711")).andReturn(new String[] {"4711"}).atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade);
     expect(itsFacade.exists("4711")).andReturn(false).atLeastOnce();
 
     replayMocks();
@@ -234,6 +268,7 @@
     expect(issueExtractor.getIssueIds("bug#4711, bug#42"))
         .andReturn(new String[] {"4711", "42"})
         .atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade).anyTimes();
     expect(itsFacade.exists("4711")).andReturn(true).atLeastOnce();
     expect(itsFacade.exists("42")).andReturn(true).atLeastOnce();
 
@@ -260,6 +295,7 @@
     expect(issueExtractor.getIssueIds("bug#4711, bug#42"))
         .andReturn(new String[] {"4711", "42"})
         .atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade).anyTimes();
     expect(itsFacade.exists("4711")).andReturn(true).atLeastOnce();
     expect(itsFacade.exists("42")).andReturn(true).atLeastOnce();
 
@@ -287,9 +323,9 @@
     expect(issueExtractor.getIssueIds("bug#4711, bug#42"))
         .andReturn(new String[] {"4711", "42"})
         .atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade).anyTimes();
     expect(itsFacade.exists("4711")).andReturn(false).atLeastOnce();
     expect(itsFacade.exists("42")).andReturn(true).atLeastOnce();
-
     replayMocks();
 
     ret = ivc.onCommitReceived(event);
@@ -321,6 +357,7 @@
     expect(issueExtractor.getIssueIds("bug#4711, bug#42"))
         .andReturn(new String[] {"4711", "42"})
         .atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade).anyTimes();
     expect(itsFacade.exists("4711")).andReturn(false).atLeastOnce();
     expect(itsFacade.exists("42")).andReturn(true).atLeastOnce();
 
@@ -353,6 +390,7 @@
     expect(issueExtractor.getIssueIds("bug#4711, bug#42"))
         .andReturn(new String[] {"4711", "42"})
         .atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade).anyTimes();
     expect(itsFacade.exists("4711")).andReturn(false).atLeastOnce();
     expect(itsFacade.exists("42")).andReturn(false).atLeastOnce();
 
@@ -387,8 +425,9 @@
     expect(issueExtractor.getIssueIds("bug#4711, bug#42"))
         .andReturn(new String[] {"4711", "42"})
         .atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade).anyTimes();
     expect(itsFacade.exists("4711")).andReturn(false).atLeastOnce();
-    expect(itsFacade.exists("42")).andReturn(false).atLeastOnce();
+    expect(itsFacade.exists("42")).andReturn(true).atLeastOnce();
 
     replayMocks();
 
@@ -419,6 +458,7 @@
     expect(issueExtractor.getIssueIds("bug#4711, bug#42"))
         .andReturn(new String[] {"4711", "42"})
         .atLeastOnce();
+    expect(itsFacadeFactory.getFacade(project.getNameKey())).andReturn(itsFacade).anyTimes();
     expect(itsFacade.exists("4711")).andThrow(new IOException("InjectedEx1")).atLeastOnce();
     expect(itsFacade.exists("42")).andReturn(false).atLeastOnce();
 
@@ -506,6 +546,9 @@
 
       itsConfig = createMock(ItsConfig.class);
       bind(ItsConfig.class).toInstance(itsConfig);
+
+      itsFacadeFactory = createMock(ItsFacadeFactory.class);
+      bind(ItsFacadeFactory.class).toInstance(itsFacadeFactory);
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionControllerTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionControllerTest.java
index d2926f9..bd32089 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionControllerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionControllerTest.java
@@ -16,11 +16,12 @@
 import static org.easymock.EasyMock.anyObject;
 import static org.easymock.EasyMock.expect;
 
-import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.events.ChangeEvent;
-import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.RefEvent;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
@@ -28,6 +29,8 @@
 import com.googlesource.gerrit.plugins.its.base.util.PropertyExtractor;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 
 public class ActionControllerTest extends LoggingMockingTestCase {
@@ -43,125 +46,100 @@
 
     ChangeEvent event = createMock(ChangeEvent.class);
 
-    Set<Set<Property>> propertySets = Collections.emptySet();
-    expect(propertyExtractor.extractFrom(event)).andReturn(propertySets).anyTimes();
+    Set<Map<String, String>> propertySets = new HashSet<>();
+    expect(propertyExtractor.extractFrom(event))
+        .andReturn(new RefEventProperties(Collections.emptyMap(), propertySets))
+        .anyTimes();
 
     replayMocks();
 
     actionController.onEvent(event);
   }
 
-  public void testNoActions() {
+  public void testNoActionsOrNoIssues() {
     ActionController actionController = createActionController();
 
     ChangeEvent event = createMock(ChangeEvent.class);
 
-    Set<Set<Property>> propertySets = Sets.newHashSet();
-    Set<Property> propertySet = Collections.emptySet();
-    propertySets.add(propertySet);
+    Set<Map<String, String>> propertySets = new HashSet<>();
+    Map<String, String> properties = ImmutableMap.of("fake", "property");
+    propertySets.add(properties);
 
-    expect(propertyExtractor.extractFrom(event)).andReturn(propertySets).anyTimes();
+    expect(propertyExtractor.extractFrom(event))
+        .andReturn(new RefEventProperties(properties, propertySets))
+        .anyTimes();
 
+    // When no issues are found in the commit message, the list of actions is empty
+    // as there are no matchs with an empty map of properties.
     Collection<ActionRequest> actions = Collections.emptySet();
-    expect(ruleBase.actionRequestsFor(propertySet)).andReturn(actions).once();
+    expect(ruleBase.actionRequestsFor(properties)).andReturn(actions).times(2);
 
     replayMocks();
 
     actionController.onEvent(event);
   }
 
-  public void testNoIssues() {
+  public void testSinglePropertyMapSingleIssueActionSingleProjectAction() {
     ActionController actionController = createActionController();
 
     ChangeEvent event = createMock(ChangeEvent.class);
 
-    Set<Set<Property>> propertySets = Sets.newHashSet();
-    Set<Property> propertySet = Collections.emptySet();
-    propertySets.add(propertySet);
+    Map<String, String> projectProperties = ImmutableMap.of("its-project", "itsProject");
 
-    expect(propertyExtractor.extractFrom(event)).andReturn(propertySets).anyTimes();
+    Map<String, String> issueProperties =
+        ImmutableMap.<String, String>builder()
+            .putAll(projectProperties)
+            .put("issue", "testIssue")
+            .build();
 
-    Collection<ActionRequest> actions = Lists.newArrayListWithCapacity(1);
-    ActionRequest action1 = createMock(ActionRequest.class);
-    actions.add(action1);
-    expect(ruleBase.actionRequestsFor(propertySet)).andReturn(actions).once();
+    Set<Map<String, String>> propertySets = ImmutableSet.of(issueProperties);
+
+    expect(propertyExtractor.extractFrom(event))
+        .andReturn(new RefEventProperties(projectProperties, propertySets))
+        .anyTimes();
+
+    ActionRequest issueActionRequest1 = createMock(ActionRequest.class);
+    Collection<ActionRequest> issueActionRequests = ImmutableList.of(issueActionRequest1);
+    expect(ruleBase.actionRequestsFor(issueProperties)).andReturn(issueActionRequests).once();
+
+    ActionRequest projectActionRequest1 = createMock(ActionRequest.class);
+    Collection<ActionRequest> projectActionRequests = ImmutableList.of(projectActionRequest1);
+    expect(ruleBase.actionRequestsFor(projectProperties)).andReturn(projectActionRequests).once();
+
+    actionExecutor.executeOnIssue(issueActionRequests, issueProperties);
+    actionExecutor.executeOnProject(projectActionRequests, projectProperties);
 
     replayMocks();
 
     actionController.onEvent(event);
   }
 
-  public void testSinglePropertySetSingleActionSingleIssue() {
+  public void testMultiplePropertyMapsMultipleActionMultipleIssue() {
     ActionController actionController = createActionController();
 
     ChangeEvent event = createMock(ChangeEvent.class);
 
-    Property propertyIssue1 = createMock(Property.class);
-    expect(propertyIssue1.getKey()).andReturn("issue").anyTimes();
-    expect(propertyIssue1.getValue()).andReturn("testIssue").anyTimes();
+    Map<String, String> properties1 = ImmutableMap.of("issue", "testIssue");
+    Map<String, String> properties2 = ImmutableMap.of("issue", "testIssue2");
 
-    Set<Property> propertySet = Sets.newHashSet();
-    propertySet.add(propertyIssue1);
+    Set<Map<String, String>> propertySets = ImmutableSet.of(properties1, properties2);
 
-    Set<Set<Property>> propertySets = Sets.newHashSet();
-    propertySets.add(propertySet);
+    expect(propertyExtractor.extractFrom(event))
+        .andReturn(new RefEventProperties(Collections.emptyMap(), propertySets))
+        .anyTimes();
 
-    expect(propertyExtractor.extractFrom(event)).andReturn(propertySets).anyTimes();
-
-    Collection<ActionRequest> actionRequests = Lists.newArrayListWithCapacity(1);
     ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    actionRequests.add(actionRequest1);
-    expect(ruleBase.actionRequestsFor(propertySet)).andReturn(actionRequests).once();
+    Collection<ActionRequest> actionRequests1 = ImmutableList.of(actionRequest1);
 
-    actionExecutor.execute("testIssue", actionRequests, propertySet);
-
-    replayMocks();
-
-    actionController.onEvent(event);
-  }
-
-  public void testMultiplePropertySetsMultipleActionMultipleIssue() {
-    ActionController actionController = createActionController();
-
-    ChangeEvent event = createMock(ChangeEvent.class);
-
-    Property propertyIssue1 = createMock(Property.class);
-    expect(propertyIssue1.getKey()).andReturn("issue").anyTimes();
-    expect(propertyIssue1.getValue()).andReturn("testIssue").anyTimes();
-
-    Property propertyIssue2 = createMock(Property.class);
-    expect(propertyIssue2.getKey()).andReturn("issue").anyTimes();
-    expect(propertyIssue2.getValue()).andReturn("testIssue2").anyTimes();
-
-    Set<Property> propertySet1 = Sets.newHashSet();
-    propertySet1.add(propertyIssue1);
-
-    Set<Property> propertySet2 = Sets.newHashSet();
-    propertySet2.add(propertyIssue1);
-    propertySet2.add(propertyIssue2);
-
-    Set<Set<Property>> propertySets = Sets.newHashSet();
-    propertySets.add(propertySet1);
-    propertySets.add(propertySet2);
-
-    expect(propertyExtractor.extractFrom(event)).andReturn(propertySets).anyTimes();
-
-    Collection<ActionRequest> actionRequests1 = Lists.newArrayListWithCapacity(1);
-    ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    actionRequests1.add(actionRequest1);
-
-    Collection<ActionRequest> actionRequests2 = Lists.newArrayListWithCapacity(2);
     ActionRequest actionRequest2 = createMock(ActionRequest.class);
-    actionRequests2.add(actionRequest2);
     ActionRequest actionRequest3 = createMock(ActionRequest.class);
-    actionRequests2.add(actionRequest3);
+    Collection<ActionRequest> actionRequests2 = ImmutableList.of(actionRequest2, actionRequest3);
 
-    expect(ruleBase.actionRequestsFor(propertySet1)).andReturn(actionRequests1).once();
-    expect(ruleBase.actionRequestsFor(propertySet2)).andReturn(actionRequests2).once();
+    expect(ruleBase.actionRequestsFor(properties1)).andReturn(actionRequests1).once();
+    expect(ruleBase.actionRequestsFor(properties2)).andReturn(actionRequests2).once();
 
-    actionExecutor.execute("testIssue", actionRequests1, propertySet1);
-    actionExecutor.execute("testIssue", actionRequests2, propertySet2);
-    actionExecutor.execute("testIssue2", actionRequests2, propertySet2);
+    actionExecutor.executeOnIssue(actionRequests1, properties1);
+    actionExecutor.executeOnIssue(actionRequests2, properties2);
 
     replayMocks();
 
@@ -173,7 +151,7 @@
   }
 
   private void setupCommonMocks() {
-    expect(itsConfig.isEnabled(anyObject(Event.class))).andReturn(true).anyTimes();
+    expect(itsConfig.isEnabled(anyObject(RefEvent.class))).andReturn(true).anyTimes();
   }
 
   @Override
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutorTest.java
index f2b6a55..e82ffd1 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutorTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ActionExecutorTest.java
@@ -16,52 +16,71 @@
 import static org.easymock.EasyMock.expect;
 import static org.easymock.EasyMock.expectLastCall;
 
-import com.google.common.collect.Sets;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacadeFactory;
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddSoyComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddStandardComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddVelocityComment;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.LogEvent;
 import java.io.IOException;
 import java.util.Collections;
+import java.util.Map;
 import java.util.Set;
 
 public class ActionExecutorTest extends LoggingMockingTestCase {
+
+  private static final String CUSTOM_ACTION_NAME = "custom-action-name";
+
   private Injector injector;
 
   private ItsFacade its;
+  private ItsFacadeFactory itsFacadeFactory;
   private AddComment.Factory addCommentFactory;
   private AddStandardComment.Factory addStandardCommentFactory;
-  private AddVelocityComment.Factory addVelocityCommentFactory;
   private AddSoyComment.Factory addSoyCommentFactory;
   private LogEvent.Factory logEventFactory;
+  private AddPropertyToField.Factory addPropertyToFieldFactory;
+  private CreateVersionFromProperty.Factory createVersionFromPropertyFactory;
+  private CustomAction customAction;
+
+  private Map<String, String> properties =
+      ImmutableMap.of("issue", "4711", "project", "testProject");
+  private Map<String, String> projectProperties =
+      ImmutableMap.<String, String>builder()
+          .putAll(properties)
+          .put("its-project", "itsTestProject")
+          .build();
 
   public void testExecuteItem() throws IOException {
     ActionRequest actionRequest = createMock(ActionRequest.class);
     expect(actionRequest.getName()).andReturn("unparsed");
     expect(actionRequest.getUnparsed()).andReturn("unparsed action 1");
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
 
     its.performAction("4711", "unparsed action 1");
 
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", actionRequest, properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
   }
 
   public void testExecuteItemException() throws IOException {
     ActionRequest actionRequest = createMock(ActionRequest.class);
     expect(actionRequest.getName()).andReturn("unparsed");
     expect(actionRequest.getUnparsed()).andReturn("unparsed action 1");
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
 
     its.performAction("4711", "unparsed action 1");
     expectLastCall().andThrow(new IOException("injected exception 1"));
@@ -69,7 +88,7 @@
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", actionRequest, properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
 
     assertLogThrowableMessageContains("injected exception 1");
   }
@@ -82,8 +101,11 @@
     ActionRequest actionRequest2 = createMock(ActionRequest.class);
     expect(actionRequest2.getName()).andReturn("unparsed");
     expect(actionRequest2.getUnparsed()).andReturn("unparsed action 2");
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its)
+        .anyTimes();
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest1, actionRequest2);
 
     its.performAction("4711", "unparsed action 1");
     its.performAction("4711", "unparsed action 2");
@@ -91,7 +113,7 @@
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", Sets.newHashSet(actionRequest1, actionRequest2), properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
   }
 
   public void testExecuteIterableExceptions() throws IOException {
@@ -106,8 +128,12 @@
     ActionRequest actionRequest3 = createMock(ActionRequest.class);
     expect(actionRequest3.getName()).andReturn("unparsed");
     expect(actionRequest3.getUnparsed()).andReturn("unparsed action 3");
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its)
+        .anyTimes();
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests =
+        ImmutableSet.of(actionRequest1, actionRequest2, actionRequest3);
 
     its.performAction("4711", "unparsed action 1");
     expectLastCall().andThrow(new IOException("injected exception 1"));
@@ -118,8 +144,7 @@
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute(
-        "4711", Sets.newHashSet(actionRequest1, actionRequest2, actionRequest3), properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
 
     assertLogThrowableMessageContains("injected exception 1");
     assertLogThrowableMessageContains("injected exception 3");
@@ -129,85 +154,149 @@
     ActionRequest actionRequest = createMock(ActionRequest.class);
     expect(actionRequest.getName()).andReturn("add-comment");
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
 
     AddComment addComment = createMock(AddComment.class);
     expect(addCommentFactory.create()).andReturn(addComment);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
 
-    addComment.execute("4711", actionRequest, properties);
+    addComment.execute(its, "4711", actionRequest, properties);
 
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", actionRequest, properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
   }
 
   public void testAddSoyCommentDelegation() throws IOException {
     ActionRequest actionRequest = createMock(ActionRequest.class);
     expect(actionRequest.getName()).andReturn("add-soy-comment");
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
 
     AddSoyComment addSoyComment = createMock(AddSoyComment.class);
     expect(addSoyCommentFactory.create()).andReturn(addSoyComment);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
 
-    addSoyComment.execute("4711", actionRequest, properties);
+    addSoyComment.execute(its, "4711", actionRequest, properties);
 
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", actionRequest, properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
   }
 
   public void testAddStandardCommentDelegation() throws IOException {
     ActionRequest actionRequest = createMock(ActionRequest.class);
     expect(actionRequest.getName()).andReturn("add-standard-comment");
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
 
     AddStandardComment addStandardComment = createMock(AddStandardComment.class);
     expect(addStandardCommentFactory.create()).andReturn(addStandardComment);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
 
-    addStandardComment.execute("4711", actionRequest, properties);
+    addStandardComment.execute(its, "4711", actionRequest, properties);
 
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", actionRequest, properties);
-  }
-
-  public void testAddVelocityCommentDelegation() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getName()).andReturn("add-velocity-comment");
-
-    Set<Property> properties = Collections.emptySet();
-
-    AddVelocityComment addVelocityComment = createMock(AddVelocityComment.class);
-    expect(addVelocityCommentFactory.create()).andReturn(addVelocityComment);
-
-    addVelocityComment.execute("4711", actionRequest, properties);
-
-    replayMocks();
-
-    ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", actionRequest, properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
   }
 
   public void testLogEventDelegation() throws IOException {
     ActionRequest actionRequest = createMock(ActionRequest.class);
     expect(actionRequest.getName()).andReturn("log-event");
 
-    Set<Property> properties = Collections.emptySet();
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
 
     LogEvent logEvent = createMock(LogEvent.class);
     expect(logEventFactory.create()).andReturn(logEvent);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
 
-    logEvent.execute("4711", actionRequest, properties);
+    logEvent.execute(its, "4711", actionRequest, properties);
 
     replayMocks();
 
     ActionExecutor actionExecutor = createActionExecutor();
-    actionExecutor.execute("4711", actionRequest, properties);
+    actionExecutor.executeOnIssue(actionRequests, properties);
+  }
+
+  public void testCreateVersionFromPropertyDelegation() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getName()).andReturn("create-version-from-property");
+
+    CreateVersionFromProperty createVersionFromProperty =
+        createMock(CreateVersionFromProperty.class);
+    expect(createVersionFromPropertyFactory.create()).andReturn(createVersionFromProperty);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
+
+    createVersionFromProperty.execute(its, "itsTestProject", actionRequest, projectProperties);
+
+    replayMocks();
+
+    ActionExecutor actionExecutor = createActionExecutor();
+    actionExecutor.executeOnProject(Collections.singleton(actionRequest), projectProperties);
+  }
+
+  public void testAddPropertyToFieldDelegation() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getName()).andReturn("add-property-to-field");
+
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
+
+    AddPropertyToField addPropertyToField = createMock(AddPropertyToField.class);
+    expect(addPropertyToFieldFactory.create()).andReturn(addPropertyToField);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
+
+    addPropertyToField.execute(its, "4711", actionRequest, properties);
+
+    replayMocks();
+
+    ActionExecutor actionExecutor = createActionExecutor();
+    actionExecutor.executeOnIssue(actionRequests, properties);
+  }
+
+  public void testExecuteIssueCustomAction() throws IOException {
+    expect(customAction.getType()).andReturn(ActionType.ISSUE);
+
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getName()).andReturn(CUSTOM_ACTION_NAME);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
+
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
+
+    customAction.execute(its, "4711", actionRequest, properties);
+
+    replayMocks();
+
+    ActionExecutor actionExecutor = createActionExecutor();
+    actionExecutor.executeOnIssue(actionRequests, properties);
+  }
+
+  public void testExecuteProjectCustomAction() throws IOException {
+    expect(customAction.getType()).andReturn(ActionType.PROJECT);
+
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getName()).andReturn(CUSTOM_ACTION_NAME);
+    expect(itsFacadeFactory.getFacade(new Project.NameKey(properties.get("project"))))
+        .andReturn(its);
+
+    Set<ActionRequest> actionRequests = ImmutableSet.of(actionRequest);
+
+    customAction.execute(its, "itsTestProject", actionRequest, projectProperties);
+
+    replayMocks();
+
+    ActionExecutor actionExecutor = createActionExecutor();
+    actionExecutor.executeOnProject(actionRequests, projectProperties);
   }
 
   private ActionExecutor createActionExecutor() {
@@ -235,11 +324,24 @@
       addStandardCommentFactory = createMock(AddStandardComment.Factory.class);
       bind(AddStandardComment.Factory.class).toInstance(addStandardCommentFactory);
 
-      addVelocityCommentFactory = createMock(AddVelocityComment.Factory.class);
-      bind(AddVelocityComment.Factory.class).toInstance(addVelocityCommentFactory);
-
       logEventFactory = createMock(LogEvent.Factory.class);
       bind(LogEvent.Factory.class).toInstance(logEventFactory);
+
+      itsFacadeFactory = createMock(ItsFacadeFactory.class);
+      bind(ItsFacadeFactory.class).toInstance(itsFacadeFactory);
+
+      addPropertyToFieldFactory = createMock(AddPropertyToField.Factory.class);
+      bind(AddPropertyToField.Factory.class).toInstance(addPropertyToFieldFactory);
+
+      createVersionFromPropertyFactory = createMock(CreateVersionFromProperty.Factory.class);
+      bind(CreateVersionFromProperty.Factory.class).toInstance(createVersionFromPropertyFactory);
+
+      DynamicMap.mapOf(binder(), CustomAction.class);
+      customAction = createMock(CustomAction.class);
+
+      bind(CustomAction.class)
+          .annotatedWith(Exports.named(CUSTOM_ACTION_NAME))
+          .toInstance(customAction);
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddCommentTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddCommentTest.java
similarity index 87%
rename from src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddCommentTest.java
rename to src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddCommentTest.java
index d16bab2..149dfca 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddCommentTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddCommentTest.java
@@ -11,18 +11,17 @@
 // 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.its.base.workflow.action;
+package com.googlesource.gerrit.plugins.its.base.workflow;
 
 import static org.easymock.EasyMock.expect;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
 import java.io.IOException;
-import java.util.HashSet;
 
 public class AddCommentTest extends LoggingMockingTestCase {
   private Injector injector;
@@ -36,7 +35,7 @@
     replayMocks();
 
     AddComment addComment = createAddComment();
-    addComment.execute("4711", actionRequest, new HashSet<>());
+    addComment.execute(null, "4711", actionRequest, ImmutableMap.of());
   }
 
   public void testPlain() throws IOException {
@@ -48,7 +47,7 @@
     replayMocks();
 
     AddComment addComment = createAddComment();
-    addComment.execute("4711", actionRequest, new HashSet<>());
+    addComment.execute(its, "4711", actionRequest, ImmutableMap.of());
   }
 
   private AddComment createAddComment() {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParametersExtractorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParametersExtractorTest.java
new file mode 100644
index 0000000..fb047f7
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldParametersExtractorTest.java
@@ -0,0 +1,105 @@
+// 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.its.base.workflow;
+
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.testutil.MockingTestCase;
+import java.util.Collections;
+import java.util.Optional;
+
+public class AddPropertyToFieldParametersExtractorTest extends MockingTestCase {
+
+  private static final String FIELD_ID = "fieldId";
+  private static final String PROPERTY_ID = "propertyId";
+  private static final String PROPERTY_VALUE = "propertyValue";
+
+  private AddPropertyToFieldParametersExtractor extractor;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    Injector injector = Guice.createInjector(new TestModule());
+    extractor = injector.getInstance(AddPropertyToFieldParametersExtractor.class);
+  }
+
+  private class TestModule extends FactoryModule {}
+
+  public void testNoParameter() {
+    testWrongNumberOfReceivedParameters(new String[] {});
+  }
+
+  public void testOneParameter() {
+    testWrongNumberOfReceivedParameters(new String[] {PROPERTY_ID});
+  }
+
+  public void testThreeParameters() {
+    testWrongNumberOfReceivedParameters(new String[] {PROPERTY_ID, PROPERTY_ID, PROPERTY_ID});
+  }
+
+  private void testWrongNumberOfReceivedParameters(String[] parameters) {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(parameters);
+
+    replayMocks();
+
+    assertFalse(extractor.extract(actionRequest, Collections.emptyMap()).isPresent());
+  }
+
+  public void testBlankFieldId() {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(new String[] {PROPERTY_ID, ""});
+
+    replayMocks();
+
+    assertFalse(extractor.extract(actionRequest, Collections.emptyMap()).isPresent());
+  }
+
+  public void testBlankPropertyId() {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(new String[] {"", FIELD_ID});
+
+    replayMocks();
+
+    assertFalse(extractor.extract(actionRequest, Collections.emptyMap()).isPresent());
+  }
+
+  public void testUnknownPropertyId() {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(new String[] {FIELD_ID, PROPERTY_ID});
+
+    replayMocks();
+
+    assertFalse(extractor.extract(actionRequest, Collections.emptyMap()).isPresent());
+  }
+
+  public void testHappyPath() {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(new String[] {PROPERTY_ID, FIELD_ID});
+
+    replayMocks();
+
+    Optional<AddPropertyToFieldParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.singletonMap(PROPERTY_ID, PROPERTY_VALUE));
+    if (!extractedParameters.isPresent()) {
+      fail();
+    }
+    assertEquals(PROPERTY_VALUE, extractedParameters.get().getPropertyValue());
+    assertEquals(FIELD_ID, extractedParameters.get().getFieldId());
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldTest.java
new file mode 100644
index 0000000..a601b04
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddPropertyToFieldTest.java
@@ -0,0 +1,77 @@
+// 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.its.base.workflow;
+
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.testutil.MockingTestCase;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import org.easymock.EasyMock;
+
+public class AddPropertyToFieldTest extends MockingTestCase {
+
+  private static final String ISSUE_ID = "4711";
+  private static final String FIELD_ID = "fieldId";
+  private static final String PROPERTY_ID = "propertyId";
+  private static final String PROPERTY_VALUE = "propertyValue";
+
+  private Injector injector;
+  private AddPropertyToFieldParametersExtractor parametersExtractor;
+  private ItsFacade its;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      parametersExtractor = createMock(AddPropertyToFieldParametersExtractor.class);
+      bind(AddPropertyToFieldParametersExtractor.class).toInstance(parametersExtractor);
+
+      its = createMock(ItsFacade.class);
+      bind(ItsFacade.class).toInstance(its);
+    }
+  }
+
+  public void testHappyPath() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties = Collections.singletonMap(PROPERTY_ID, PROPERTY_VALUE);
+    expect(parametersExtractor.extract(actionRequest, properties))
+        .andReturn(Optional.of(new AddPropertyToFieldParameters(PROPERTY_VALUE, FIELD_ID)));
+
+    its.addValueToField(ISSUE_ID, PROPERTY_VALUE, FIELD_ID);
+    EasyMock.expectLastCall().once();
+
+    replayMocks();
+
+    AddPropertyToField addPropertyToField = createAddPropertyToField();
+    addPropertyToField.execute(its, ISSUE_ID, actionRequest, properties);
+  }
+
+  private AddPropertyToField createAddPropertyToField() {
+    return injector.getInstance(AddPropertyToField.class);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddStandardCommentTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddStandardCommentTest.java
new file mode 100644
index 0000000..d695bd1
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/AddStandardCommentTest.java
@@ -0,0 +1,197 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.its.base.workflow;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
+import java.io.IOException;
+import java.util.Map;
+
+public class AddStandardCommentTest extends LoggingMockingTestCase {
+  private Injector injector;
+
+  private ItsFacade its;
+
+  public void testChangeMergedPlain() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties = ImmutableMap.of("event-type", "change-merged");
+
+    its.addComment("42", "Change merged");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "42", actionRequest, properties);
+  }
+
+  public void testChangeMergedFull() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put("event-type", "change-merged")
+            .put("subject", "Test-Change-Subject")
+            .put("changeNumber", "4711")
+            .put("submitterName", "John Doe")
+            .put("formatChangeUrl", "HtTp://ExAmPlE.OrG/ChAnGe")
+            .build();
+
+    its.addComment(
+        "176",
+        "Change 4711 merged by John Doe:\n"
+            + "Test-Change-Subject\n"
+            + "\n"
+            + "HtTp://ExAmPlE.OrG/ChAnGe");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "176", actionRequest, properties);
+  }
+
+  public void testChangeAbandonedPlain() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties = ImmutableMap.of("event-type", "change-abandoned");
+
+    its.addComment("42", "Change abandoned");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "42", actionRequest, properties);
+  }
+
+  public void testChangeAbandonedFull() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put("event-type", "change-abandoned")
+            .put("reason", "Test-Reason")
+            .put("subject", "Test-Change-Subject")
+            .put("changeNumber", "4711")
+            .put("abandonerName", "John Doe")
+            .put("formatChangeUrl", "HtTp://ExAmPlE.OrG/ChAnGe")
+            .build();
+
+    its.addComment(
+        "176",
+        "Change 4711 abandoned by John Doe:\n"
+            + "Test-Change-Subject\n"
+            + "\n"
+            + "Reason:\n"
+            + "Test-Reason\n"
+            + "\n"
+            + "HtTp://ExAmPlE.OrG/ChAnGe");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "176", actionRequest, properties);
+  }
+
+  public void testChangeRestoredPlain() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties = ImmutableMap.of("event-type", "change-restored");
+
+    its.addComment("42", "Change restored");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "42", actionRequest, properties);
+  }
+
+  public void testChangeRestoredFull() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put("event-type", "change-restored")
+            .put("reason", "Test-Reason")
+            .put("subject", "Test-Change-Subject")
+            .put("changeNumber", "4711")
+            .put("restorerName", "John Doe")
+            .put("formatChangeUrl", "HtTp://ExAmPlE.OrG/ChAnGe")
+            .build();
+
+    its.addComment(
+        "176",
+        "Change 4711 restored by John Doe:\n"
+            + "Test-Change-Subject\n"
+            + "\n"
+            + "Reason:\n"
+            + "Test-Reason\n"
+            + "\n"
+            + "HtTp://ExAmPlE.OrG/ChAnGe");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "176", actionRequest, properties);
+  }
+
+  public void testPatchSetCreatedPlain() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties = ImmutableMap.of("event-type", "patchset-created");
+
+    its.addComment("42", "Change had a related patch set uploaded");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "42", actionRequest, properties);
+  }
+
+  public void testPatchSetCreatedFull() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put("event-type", "patchset-created")
+            .put("subject", "Test-Change-Subject")
+            .put("changeNumber", "4711")
+            .put("uploaderName", "John Doe")
+            .put("formatChangeUrl", "HtTp://ExAmPlE.OrG/ChAnGe")
+            .build();
+
+    its.addComment(
+        "176",
+        "Change 4711 had a related patch set uploaded by "
+            + "John Doe:\n"
+            + "Test-Change-Subject\n"
+            + "\n"
+            + "HtTp://ExAmPlE.OrG/ChAnGe");
+    replayMocks();
+
+    StandardAction action = injector.getInstance(AddStandardComment.class);
+    action.execute(its, "176", actionRequest, properties);
+  }
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      its = createMock(ItsFacade.class);
+      bind(ItsFacade.class).toInstance(its);
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ConditionTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ConditionTest.java
index c1a6a8d..3df38cc 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ConditionTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ConditionTest.java
@@ -13,15 +13,12 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.its.base.workflow;
 
-import static org.easymock.EasyMock.expect;
-
-import com.google.common.collect.Lists;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import java.util.Collection;
-import java.util.Collections;
+import java.util.Map;
 
 public class ConditionTest extends LoggingMockingTestCase {
   private Injector injector;
@@ -34,12 +31,7 @@
   public void testIsMetBySimple() {
     Condition condition = createCondition("testKey", "testValue");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("testValue").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("testKey", "testValue");
 
     replayMocks();
 
@@ -49,7 +41,7 @@
   public void testIsMetBySimpleEmpty() {
     Condition condition = createCondition("testKey", "testValue");
 
-    Collection<Property> properties = Collections.emptySet();
+    Map<String, String> properties = ImmutableMap.of();
 
     replayMocks();
 
@@ -59,12 +51,7 @@
   public void testIsMetByMismatchedKey() {
     Condition condition = createCondition("testKey", "testValue");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("otherKey").anyTimes();
-    expect(property1.getValue()).andReturn("testValue").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("otherKey", "testValue");
 
     replayMocks();
 
@@ -74,12 +61,7 @@
   public void testIsMetByMismatchedValue() {
     Condition condition = createCondition("testKey", "testValue");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("otherValue").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("testKey", "otherValue");
 
     replayMocks();
 
@@ -89,12 +71,7 @@
   public void testIsMetByOredSingle() {
     Condition condition = createCondition("testKey", "value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("value2").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("testKey", "value2");
 
     replayMocks();
 
@@ -104,17 +81,7 @@
   public void testIsMetByOredMultiple() {
     Condition condition = createCondition("testKey", "value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("value1").anyTimes();
-
-    Property property2 = createMock(Property.class);
-    expect(property2.getKey()).andReturn("testKey").anyTimes();
-    expect(property2.getValue()).andReturn("value3").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(2);
-    properties.add(property1);
-    properties.add(property2);
+    Map<String, String> properties = ImmutableMap.of("testKey", "value1 value3");
 
     replayMocks();
 
@@ -122,19 +89,9 @@
   }
 
   public void testIsMetByOredMultipleWithSpaces() {
-    Condition condition = createCondition("testKey", "value1, value2, value3");
+    Condition condition = createCondition("testKey", "value1, value2 value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("value1").anyTimes();
-
-    Property property2 = createMock(Property.class);
-    expect(property2.getKey()).andReturn("testKey").anyTimes();
-    expect(property2.getValue()).andReturn("value3").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(2);
-    properties.add(property1);
-    properties.add(property2);
+    Map<String, String> properties = ImmutableMap.of("testKey", "value1 value3");
 
     replayMocks();
 
@@ -144,22 +101,7 @@
   public void testIsMetByOredAll() {
     Condition condition = createCondition("testKey", "value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("value1").anyTimes();
-
-    Property property2 = createMock(Property.class);
-    expect(property2.getKey()).andReturn("testKey").anyTimes();
-    expect(property2.getValue()).andReturn("value2").anyTimes();
-
-    Property property3 = createMock(Property.class);
-    expect(property3.getKey()).andReturn("testKey").anyTimes();
-    expect(property3.getValue()).andReturn("value3").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
-    properties.add(property2);
-    properties.add(property3);
+    Map<String, String> properties = ImmutableMap.of("testKey", "value1 value2 value3");
 
     replayMocks();
 
@@ -169,22 +111,7 @@
   public void testIsMetByOredOvershoot() {
     Condition condition = createCondition("testKey", "value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("otherValue1").anyTimes();
-
-    Property property2 = createMock(Property.class);
-    expect(property2.getKey()).andReturn("testKey").anyTimes();
-    expect(property2.getValue()).andReturn("value2").anyTimes();
-
-    Property property3 = createMock(Property.class);
-    expect(property3.getKey()).andReturn("testKey").anyTimes();
-    expect(property3.getValue()).andReturn("otherValue3").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(3);
-    properties.add(property1);
-    properties.add(property2);
-    properties.add(property3);
+    Map<String, String> properties = ImmutableMap.of("testKey", "otherValue1 value2 otherValue3");
 
     replayMocks();
 
@@ -194,7 +121,7 @@
   public void testNegatedIsMetByEmpty() {
     Condition condition = createCondition("testKey", "!,testValue");
 
-    Collection<Property> properties = Collections.emptySet();
+    Map<String, String> properties = ImmutableMap.of();
 
     replayMocks();
 
@@ -204,12 +131,7 @@
   public void testNegatedIsMetByMismatchedKey() {
     Condition condition = createCondition("testKey", "!,testValue");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("otherKey").anyTimes();
-    expect(property1.getValue()).andReturn("testValue").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("otherKey", "testValue");
 
     replayMocks();
 
@@ -219,12 +141,7 @@
   public void testNegatedIsMetByMaMismatchedValue() {
     Condition condition = createCondition("testKey", "!,testValue");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("otherValue").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("testKey", "otherValue");
 
     replayMocks();
 
@@ -234,12 +151,7 @@
   public void testNegatedIsMetByOredNoMatch() {
     Condition condition = createCondition("testKey", "!,value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("otherValue").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("testKey", "otherValue");
 
     replayMocks();
 
@@ -249,12 +161,7 @@
   public void testNegatedIsMetByOredSingleMatch() {
     Condition condition = createCondition("testKey", "!,value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("value1").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of("testKey", "value1");
 
     replayMocks();
 
@@ -264,17 +171,7 @@
   public void testNegatedIsMetByOredMultiple() {
     Condition condition = createCondition("testKey", "!,value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("value1").anyTimes();
-
-    Property property2 = createMock(Property.class);
-    expect(property2.getKey()).andReturn("testKey").anyTimes();
-    expect(property2.getValue()).andReturn("value3").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(2);
-    properties.add(property1);
-    properties.add(property2);
+    Map<String, String> properties = ImmutableMap.of("testKey", "value1 value3");
 
     replayMocks();
 
@@ -284,22 +181,7 @@
   public void testNegatedIsMetByOredAll() {
     Condition condition = createCondition("testKey", "!,value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("value1").anyTimes();
-
-    Property property2 = createMock(Property.class);
-    expect(property2.getKey()).andReturn("testKey").anyTimes();
-    expect(property2.getValue()).andReturn("value2").anyTimes();
-
-    Property property3 = createMock(Property.class);
-    expect(property3.getKey()).andReturn("testKey").anyTimes();
-    expect(property3.getValue()).andReturn("value3").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    properties.add(property1);
-    properties.add(property2);
-    properties.add(property3);
+    Map<String, String> properties = ImmutableMap.of("testKey", "value1 value2 value3");
 
     replayMocks();
 
@@ -309,23 +191,7 @@
   public void testNegatedIsMetByOredOvershoot() {
     Condition condition = createCondition("testKey", "!,value1,value2,value3");
 
-    Property property1 = createMock(Property.class);
-    expect(property1.getKey()).andReturn("testKey").anyTimes();
-    expect(property1.getValue()).andReturn("otherValue1").anyTimes();
-
-    Property property2 = createMock(Property.class);
-    expect(property2.getKey()).andReturn("testKey").anyTimes();
-    expect(property2.getValue()).andReturn("value2").anyTimes();
-
-    Property property3 = createMock(Property.class);
-    expect(property3.getKey()).andReturn("testKey").anyTimes();
-    expect(property3.getValue()).andReturn("otherValue3").anyTimes();
-
-    Collection<Property> properties = Lists.newArrayListWithCapacity(3);
-    properties.add(property1);
-    properties.add(property2);
-    properties.add(property3);
-
+    Map<String, String> properties = ImmutableMap.of("testKey", "otherValue1 value2 otherValue3");
     replayMocks();
 
     assertFalse("isMetBy gave true", condition.isMetBy(properties));
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParametersExtractorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParametersExtractorTest.java
new file mode 100644
index 0000000..82b33e7
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyParametersExtractorTest.java
@@ -0,0 +1,83 @@
+package com.googlesource.gerrit.plugins.its.base.workflow;
+
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.testutil.MockingTestCase;
+import java.util.Collections;
+import java.util.Optional;
+
+public class CreateVersionFromPropertyParametersExtractorTest extends MockingTestCase {
+
+  private static final String ITS_PROJECT = "test-project";
+  private static final String PROPERTY_ID = "propertyId";
+  private static final String PROPERTY_VALUE = "propertyValue";
+
+  private CreateVersionFromPropertyParametersExtractor extractor;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    Injector injector = Guice.createInjector(new TestModule());
+    extractor = injector.getInstance(CreateVersionFromPropertyParametersExtractor.class);
+  }
+
+  private class TestModule extends FactoryModule {}
+
+  public void testNoParameter() {
+    testWrongNumberOfReceivedParameters(new String[] {});
+  }
+
+  public void testTwoParameters() {
+    testWrongNumberOfReceivedParameters(new String[] {PROPERTY_ID, PROPERTY_ID});
+  }
+
+  private void testWrongNumberOfReceivedParameters(String[] parameters) {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(parameters);
+
+    replayMocks();
+
+    Optional<CreateVersionFromPropertyParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  public void testBlankPropertyId() {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(new String[] {""});
+
+    replayMocks();
+
+    Optional<CreateVersionFromPropertyParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  public void testUnknownPropertyId() {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(new String[] {PROPERTY_ID});
+
+    replayMocks();
+
+    Optional<CreateVersionFromPropertyParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  public void testHappyPath() {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameters()).andReturn(new String[] {PROPERTY_ID});
+
+    replayMocks();
+
+    Optional<CreateVersionFromPropertyParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.singletonMap(PROPERTY_ID, PROPERTY_VALUE));
+    if (!extractedParameters.isPresent()) {
+      fail();
+    }
+    assertEquals(PROPERTY_VALUE, extractedParameters.get().getPropertyValue());
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyTest.java
new file mode 100644
index 0000000..a89509c
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/CreateVersionFromPropertyTest.java
@@ -0,0 +1,76 @@
+// 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.its.base.workflow;
+
+import static org.easymock.EasyMock.expect;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.testutil.MockingTestCase;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import org.easymock.EasyMock;
+
+public class CreateVersionFromPropertyTest extends MockingTestCase {
+
+  private static final String ITS_PROJECT = "test-project";
+  private static final String PROPERTY_ID = "propertyId";
+  private static final String PROPERTY_VALUE = "propertyValue";
+
+  private Injector injector;
+  private ItsFacade its;
+  private CreateVersionFromPropertyParametersExtractor parametersExtractor;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      its = createMock(ItsFacade.class);
+      bind(ItsFacade.class).toInstance(its);
+
+      parametersExtractor = createMock(CreateVersionFromPropertyParametersExtractor.class);
+      bind(CreateVersionFromPropertyParametersExtractor.class).toInstance(parametersExtractor);
+    }
+  }
+
+  public void testHappyPath() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+
+    Map<String, String> properties = Collections.emptyMap();
+    expect(parametersExtractor.extract(actionRequest, properties))
+        .andReturn(Optional.of(new CreateVersionFromPropertyParameters(PROPERTY_VALUE)));
+
+    its.createVersion(ITS_PROJECT, PROPERTY_VALUE);
+    EasyMock.expectLastCall().once();
+
+    replayMocks();
+
+    CreateVersionFromProperty createVersionFromProperty = createCreateVersionFromProperty();
+    createVersionFromProperty.execute(its, ITS_PROJECT, actionRequest, properties);
+  }
+
+  private CreateVersionFromProperty createCreateVersionFromProperty() {
+    return injector.getInstance(CreateVersionFromProperty.class);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheTest.java
new file mode 100644
index 0000000..3b33b41
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheTest.java
@@ -0,0 +1,155 @@
+// 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.its.base.workflow;
+
+import static com.googlesource.gerrit.plugins.its.base.workflow.RulesConfigReader.ACTION_KEY;
+import static com.googlesource.gerrit.plugins.its.base.workflow.RulesConfigReader.RULE_SECTION;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.isA;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.GlobalRulesFileName;
+import com.googlesource.gerrit.plugins.its.base.PluginRulesFileName;
+import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
+import com.googlesource.gerrit.plugins.its.base.workflow.RuleBaseTest.RuleBaseKind;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+
+public class ItsRulesProjectCacheTest extends LoggingMockingTestCase {
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      rulesConfigReader = createMock(RulesConfigReader.class);
+      bind(RulesConfigReader.class).toInstance(rulesConfigReader);
+
+      projectCache = createMock(ProjectCache.class);
+      bind(ProjectCache.class).toInstance(projectCache);
+
+      bind(String.class)
+          .annotatedWith(GlobalRulesFileName.class)
+          .toInstance(RuleBaseKind.GLOBAL.fileName);
+      bind(String.class)
+          .annotatedWith(PluginRulesFileName.class)
+          .toInstance(RuleBaseKind.ITS.fileName);
+    }
+  }
+
+  private static final String ACTION_1 = "action1";
+  private static final String CONDITION_KEY = "condition";
+  private static final String RULE_1 = "rule1";
+  private static final String TEST_PROJECT = "testProject";
+  private static final String VALUE_1 = "value1";
+
+  private Injector injector;
+  private ProjectCache projectCache;
+  private RulesConfigReader rulesConfigReader;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  public void testProjectConfigIsLoaded() throws IOException {
+    Rule rule1 = new Rule(RULE_1);
+    ActionRequest action1 = new ActionRequest(ACTION_1);
+    Condition condition1 = new Condition(CONDITION_KEY, VALUE_1);
+    rule1.addActionRequest(action1);
+    rule1.addCondition(condition1);
+
+    ProjectState projectState = createMock(ProjectState.class);
+    ProjectLevelConfig projectLevelConfigGlobal = createMock(ProjectLevelConfig.class);
+    Config projectGlobalCfg = new Config();
+    projectGlobalCfg.setString(RULE_SECTION, RULE_1, CONDITION_KEY, VALUE_1);
+    projectGlobalCfg.setString(RULE_SECTION, RULE_1, ACTION_KEY, ACTION_1);
+    expect(projectLevelConfigGlobal.get()).andReturn(projectGlobalCfg);
+    expect(projectState.getConfig(RuleBaseKind.GLOBAL.fileName))
+        .andReturn(projectLevelConfigGlobal);
+    ProjectLevelConfig projectLevelConfigPlugin = createMock(ProjectLevelConfig.class);
+    expect(projectLevelConfigPlugin.get()).andReturn(new Config());
+    expect(projectState.getConfig(RuleBaseKind.ITS.fileName)).andReturn(projectLevelConfigPlugin);
+    expect(projectCache.checkedGet(new Project.NameKey(TEST_PROJECT))).andReturn(projectState);
+    expect(rulesConfigReader.getRulesFromConfig(isA(Config.class)))
+        .andReturn(ImmutableList.of(rule1))
+        .andReturn(ImmutableList.of());
+    replayMocks();
+
+    ItsRulesProjectCacheImpl.Loader loader =
+        injector.getInstance(ItsRulesProjectCacheImpl.Loader.class);
+    Collection<Rule> actual = loader.load(TEST_PROJECT);
+    List<Rule> expected = ImmutableList.of(rule1);
+
+    assertEquals("Rules do not match", expected, actual);
+    assertTrue(actual.contains(rule1));
+  }
+
+  public void testParentProjectConfigIsLoaded() throws IOException {
+    Rule rule1 = new Rule(RULE_1);
+    ActionRequest action1 = new ActionRequest(ACTION_1);
+    Condition condition1 = new Condition(CONDITION_KEY, VALUE_1);
+    rule1.addActionRequest(action1);
+    rule1.addCondition(condition1);
+
+    ProjectState projectState = createMock(ProjectState.class);
+    ProjectLevelConfig projectLevelConfigGlobal = createMock(ProjectLevelConfig.class);
+    expect(projectLevelConfigGlobal.get()).andReturn(new Config());
+    expect(projectState.getConfig(RuleBaseKind.GLOBAL.fileName))
+        .andReturn(projectLevelConfigGlobal);
+    ProjectLevelConfig projectLevelConfigPlugin = createMock(ProjectLevelConfig.class);
+    expect(projectLevelConfigPlugin.get()).andReturn(new Config());
+    expect(projectState.getConfig(RuleBaseKind.ITS.fileName)).andReturn(projectLevelConfigPlugin);
+
+    ProjectState parentProjectState = createMock(ProjectState.class);
+    ProjectLevelConfig parentProjectConfigGlobal = createMock(ProjectLevelConfig.class);
+    Config parentGlobalCfg = new Config();
+    parentGlobalCfg.setString(RULE_SECTION, RULE_1, CONDITION_KEY, VALUE_1);
+    parentGlobalCfg.setString(RULE_SECTION, RULE_1, ACTION_KEY, ACTION_1);
+    expect(parentProjectConfigGlobal.get()).andReturn(parentGlobalCfg);
+    expect(parentProjectState.getConfig(RuleBaseKind.GLOBAL.fileName))
+        .andReturn(parentProjectConfigGlobal);
+    ProjectLevelConfig parentProjectConfigPlugin = createMock(ProjectLevelConfig.class);
+    expect(parentProjectConfigPlugin.get()).andReturn(new Config());
+    expect(parentProjectState.getConfig(RuleBaseKind.ITS.fileName))
+        .andReturn(parentProjectConfigPlugin);
+    expect(projectState.parents()).andReturn(FluentIterable.of(parentProjectState));
+    expect(projectCache.checkedGet(new Project.NameKey(TEST_PROJECT))).andReturn(projectState);
+
+    expect(rulesConfigReader.getRulesFromConfig(isA(Config.class)))
+        .andReturn(ImmutableList.of())
+        .andReturn(ImmutableList.of())
+        .andReturn(ImmutableList.of(rule1))
+        .andReturn(ImmutableList.of());
+
+    replayMocks();
+
+    ItsRulesProjectCacheImpl.Loader loader =
+        injector.getInstance(ItsRulesProjectCacheImpl.Loader.class);
+    Collection<Rule> actual = loader.load(TEST_PROJECT);
+    List<Rule> expected = ImmutableList.of(rule1);
+
+    assertEquals("Rules do not match", expected, actual);
+    assertTrue(actual.contains(rule1));
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/LogEventTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/LogEventTest.java
new file mode 100644
index 0000000..0a26db8
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/LogEventTest.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2013 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.its.base.workflow;
+
+import static org.easymock.EasyMock.expect;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
+import java.io.IOException;
+import java.util.Map;
+import org.apache.log4j.Level;
+
+public class LogEventTest extends LoggingMockingTestCase {
+  private Injector injector;
+  private ItsFacade its;
+
+  public void testNull() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn(null);
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(null, "4711", actionRequest, ImmutableMap.of());
+  }
+
+  public void testEmpty() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn("");
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(null, "4711", actionRequest, ImmutableMap.of());
+  }
+
+  public void testLevelDefault() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn("");
+
+    Map<String, String> properties = ImmutableMap.of("KeyA", "ValueA");
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(its, "4711", actionRequest, properties);
+
+    assertLogMessageContains("KeyA = ValueA", Level.INFO);
+  }
+
+  public void testLevelError() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn("error");
+
+    Map<String, String> properties = ImmutableMap.of("KeyA", "ValueA");
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(its, "4711", actionRequest, properties);
+
+    assertLogMessageContains("KeyA = ValueA", Level.ERROR);
+  }
+
+  public void testLevelWarn() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn("warn");
+
+    Map<String, String> properties = ImmutableMap.of("KeyA", "ValueA");
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(its, "4711", actionRequest, properties);
+
+    assertLogMessageContains("KeyA = ValueA", Level.WARN);
+  }
+
+  public void testLevelInfo() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn("info");
+
+    Map<String, String> properties = ImmutableMap.of("KeyA", "ValueA");
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(its, "4711", actionRequest, properties);
+
+    assertLogMessageContains("KeyA = ValueA", Level.INFO);
+  }
+
+  public void testLevelDebug() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn("debug");
+
+    Map<String, String> properties = ImmutableMap.of("KeyA", "ValueA");
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(its, "4711", actionRequest, properties);
+
+    assertLogMessageContains("KeyA = ValueA", Level.DEBUG);
+  }
+
+  public void testMultipleProperties() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getParameter(1)).andReturn("info");
+
+    Map<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put("KeyA", "ValueA")
+            .put("KeyB", "ValueB")
+            .put("KeyC", "ValueC")
+            .build();
+
+    replayMocks();
+
+    LogEvent logEvent = createLogEvent();
+    logEvent.execute(its, "4711", actionRequest, properties);
+
+    assertLogMessageContains("KeyA = ValueA", Level.INFO);
+    assertLogMessageContains("KeyB = ValueB", Level.INFO);
+    assertLogMessageContains("KeyC = ValueC", Level.INFO);
+  }
+
+  private LogEvent createLogEvent() {
+    return injector.getInstance(LogEvent.class);
+  }
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      its = createMock(ItsFacade.class);
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/PropertyTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/PropertyTest.java
deleted file mode 100644
index 06ac41a..0000000
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/PropertyTest.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2013 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.its.base.workflow;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-
-public class PropertyTest extends LoggingMockingTestCase {
-  private Injector injector;
-
-  public void testGetKeyNull() {
-    Property property = new Property(null, "testValue");
-    assertNull("Key is not null", property.getKey());
-  }
-
-  public void testGetKeyNonNull() {
-    Property property = createProperty("testKey", "testValue");
-    assertEquals("Key does not match", "testKey", property.getKey());
-  }
-
-  public void testGetValueNull() {
-    Property property = createProperty("testKey", null);
-    assertNull("Value is not null", property.getValue());
-  }
-
-  public void testGetValueNonNull() {
-    Property property = createProperty("testKey", "testValue");
-    assertEquals("Value does not match", "testValue", property.getValue());
-  }
-
-  public void testEqualsSimilar() {
-    Property propertyA = createProperty("testKey", "testValue");
-    Property propertyB = createProperty("testKey", "testValue");
-    assertTrue("Property is equal to similar", propertyA.equals(propertyB));
-  }
-
-  public void testEqualsNull() {
-    Property property = createProperty("testKey", "testValue");
-    assertFalse("Property is equal to null", property.equals(null));
-  }
-
-  public void testEqualsNull2() {
-    Property property = new Property(null, null);
-    assertFalse("Property is equal to null", property.equals(null));
-  }
-
-  public void testEqualsNulledKey() {
-    Property propertyA = new Property(null, "testValue");
-    Property propertyB = createProperty("testKey", "testValue");
-    assertFalse("Single nulled key does match", propertyA.equals(propertyB));
-  }
-
-  public void testEqualsNulledKey2() {
-    Property propertyA = createProperty("testKey", "testValue");
-    Property propertyB = new Property(null, "testValue");
-    assertFalse("Single nulled key does match", propertyA.equals(propertyB));
-  }
-
-  public void testEqualsNulledValue() {
-    Property propertyA = createProperty("testKey", "testValue");
-    Property propertyB = createProperty("testKey", null);
-    assertFalse("Single nulled value does match", propertyA.equals(propertyB));
-  }
-
-  public void testEqualsNulledValue2() {
-    Property propertyA = createProperty("testKey", null);
-    Property propertyB = createProperty("testKey", "testValue");
-    assertFalse("Single nulled value does match", propertyA.equals(propertyB));
-  }
-
-  public void testHashCodeEquals() {
-    Property propertyA = createProperty("testKey", "testValue");
-    Property propertyB = createProperty("testKey", "testValue");
-    assertEquals("Hash codes do not match", propertyA.hashCode(), propertyB.hashCode());
-  }
-
-  public void testHashCodeNullKey() {
-    Property property = new Property(null, "testValue");
-    property.hashCode();
-  }
-
-  public void testHashCodeNullValue() {
-    Property property = createProperty("testKey", null);
-    property.hashCode();
-  }
-
-  private Property createProperty(String key, String value) {
-    Property.Factory factory = injector.getInstance(Property.Factory.class);
-    return factory.create(key, value);
-  }
-
-  @Override
-  public void setUp() throws Exception {
-    super.setUp();
-
-    injector = Guice.createInjector(new TestModule());
-  }
-
-  private class TestModule extends FactoryModule {
-    @Override
-    protected void configure() {
-      factory(Property.Factory.class);
-    }
-  }
-}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBaseTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBaseTest.java
index 80e12fb..305e8d1 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBaseTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleBaseTest.java
@@ -11,152 +11,76 @@
 // 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.its.base.workflow;
 
 import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.isA;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.config.SitePath;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.GlobalRulesFileName;
+import com.googlesource.gerrit.plugins.its.base.ItsPath;
+import com.googlesource.gerrit.plugins.its.base.PluginRulesFileName;
 import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileWriter;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.UUID;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.FileUtils;
 
 public class RuleBaseTest extends LoggingMockingTestCase {
+  private static final String PROJECT_KEY = "project";
+  private static final String TEST_PROJECT = "testProject";
+
   private Injector injector;
 
-  private Path sitePath;
-  private Rule.Factory ruleFactory;
-  private Condition.Factory conditionFactory;
-  private ActionRequest.Factory actionRequestFactory;
+  private Path itsPath;
+  private RulesConfigReader rulesConfigReader;
+  private ItsRulesProjectCache rulesProjectCache;
 
   private boolean cleanupSitePath;
 
-  private enum RuleBaseKind {
-    GLOBAL,
-    ITS,
-    FAULTY
-  }
+  public enum RuleBaseKind {
+    GLOBAL("actions"),
+    ITS("actions-ItsTestName");
 
-  public void testWarnNonExistingRuleBase() {
-    replayMocks();
+    String fileName;
 
-    createRuleBase();
-
-    assertLogMessageContains("Neither global");
-  }
-
-  public void testEmptyRuleBase() throws IOException {
-    injectRuleBase("");
-
-    replayMocks();
-
-    createRuleBase();
-  }
-
-  public void testSimpleRuleBase() throws IOException {
-    injectRuleBase("[rule \"rule1\"]\n" + "\tconditionA = value1\n" + "\taction = action1");
-
-    Rule rule1 = createMock(Rule.class);
-    expect(ruleFactory.create("rule1")).andReturn(rule1);
-
-    Condition condition1 = createMock(Condition.class);
-    expect(conditionFactory.create("conditionA", "value1")).andReturn(condition1);
-    rule1.addCondition(condition1);
-
-    ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action1")).andReturn(actionRequest1);
-    rule1.addActionRequest(actionRequest1);
-
-    replayMocks();
-
-    createRuleBase();
-  }
-
-  public void testBasicRuleBase() throws IOException {
-    injectRuleBase(
-        "[rule \"rule1\"]\n"
-            + "\tconditionA = value1,value2\n"
-            + "\tconditionA = value3,value of 4\n"
-            + "\tconditionB = value5\n"
-            + "\taction = action1\n"
-            + "\taction = action2 param\n"
-            + "\n"
-            + "[ruleXZ \"nonrule\"]\n"
-            + "\tconditionA = value1\n"
-            + "\taction = action2\n"
-            + "[rule \"rule2\"]\n"
-            + "\tconditionC = value6\n"
-            + "\taction = action3");
-
-    Rule rule1 = createMock(Rule.class);
-    expect(ruleFactory.create("rule1")).andReturn(rule1);
-
-    Condition condition1 = createMock(Condition.class);
-    expect(conditionFactory.create("conditionA", "value1,value2")).andReturn(condition1);
-    rule1.addCondition(condition1);
-
-    Condition condition2 = createMock(Condition.class);
-    expect(conditionFactory.create("conditionA", "value3,value of 4")).andReturn(condition2);
-    rule1.addCondition(condition2);
-
-    Condition condition3 = createMock(Condition.class);
-    expect(conditionFactory.create("conditionB", "value5")).andReturn(condition3);
-    rule1.addCondition(condition3);
-
-    ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action1")).andReturn(actionRequest1);
-    rule1.addActionRequest(actionRequest1);
-
-    ActionRequest actionRequest2 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action2 param")).andReturn(actionRequest2);
-    rule1.addActionRequest(actionRequest2);
-
-    Rule rule2 = createMock(Rule.class);
-    expect(ruleFactory.create("rule2")).andReturn(rule2);
-
-    Condition condition4 = createMock(Condition.class);
-    expect(conditionFactory.create("conditionC", "value6")).andReturn(condition4);
-    rule2.addCondition(condition4);
-
-    ActionRequest actionRequest3 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action3")).andReturn(actionRequest3);
-    rule2.addActionRequest(actionRequest3);
-
-    replayMocks();
-
-    createRuleBase();
+    RuleBaseKind(String fileName) {
+      this.fileName = fileName + ".config";
+    }
   }
 
   public void testActionRequestsForSimple() throws IOException {
-    injectRuleBase("[rule \"rule1\"]\n" + "\taction = action1");
+    String rules = "[rule \"rule1\"]\n\taction = action1\n";
+    injectRuleBase(rules);
 
     Rule rule1 = createMock(Rule.class);
-    expect(ruleFactory.create("rule1")).andReturn(rule1);
-
     ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action1")).andReturn(actionRequest1);
-    rule1.addActionRequest(actionRequest1);
 
-    Collection<Property> properties = Collections.emptySet();
+    Map<String, String> properties = ImmutableMap.of(PROJECT_KEY, TEST_PROJECT);
 
     List<ActionRequest> rule1Match = Lists.newArrayListWithCapacity(1);
     rule1Match.add(actionRequest1);
     expect(rule1.actionRequestsFor(properties)).andReturn(rule1Match);
 
+    expect(rulesConfigReader.getRulesFromConfig(isA(Config.class)))
+        .andReturn(ImmutableList.of(rule1))
+        .once();
+
+    expect(rulesProjectCache.get(TEST_PROJECT)).andReturn(ImmutableList.of());
+
     replayMocks();
 
     RuleBase ruleBase = createRuleBase();
@@ -175,163 +99,96 @@
             + "\taction = action2\n"
             + "\n"
             + "[rule \"rule2\"]\n"
-            + "\taction = action3");
+            + "\taction = action3\n");
 
     Rule rule1 = createMock(Rule.class);
-    expect(ruleFactory.create("rule1")).andReturn(rule1);
-
     ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action1")).andReturn(actionRequest1);
-    rule1.addActionRequest(actionRequest1);
-
     ActionRequest actionRequest2 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action2")).andReturn(actionRequest2);
-    rule1.addActionRequest(actionRequest2);
 
     Rule rule2 = createMock(Rule.class);
-    expect(ruleFactory.create("rule2")).andReturn(rule2);
-
     ActionRequest actionRequest3 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action3")).andReturn(actionRequest3);
-    rule2.addActionRequest(actionRequest3);
 
-    Collection<Property> properties = Lists.newArrayListWithCapacity(1);
-    Property property1 = createMock(Property.class);
-    properties.add(property1);
+    Map<String, String> properties = ImmutableMap.of(PROJECT_KEY, TEST_PROJECT);
 
-    List<ActionRequest> rule1Match = Lists.newArrayListWithCapacity(2);
-    rule1Match.add(actionRequest1);
-    rule1Match.add(actionRequest2);
-    expect(rule1.actionRequestsFor(properties)).andReturn(rule1Match);
+    List<ActionRequest> rule1Match = ImmutableList.of(actionRequest1, actionRequest2);
+    expect(rule1.actionRequestsFor(properties)).andReturn(rule1Match).anyTimes();
 
-    List<ActionRequest> rule2Match = Lists.newArrayListWithCapacity(1);
-    rule2Match.add(actionRequest3);
-    expect(rule2.actionRequestsFor(properties)).andReturn(rule2Match);
+    List<ActionRequest> rule2Match = ImmutableList.of(actionRequest3);
+    expect(rule2.actionRequestsFor(properties)).andReturn(rule2Match).anyTimes();
+
+    expect(rulesProjectCache.get(TEST_PROJECT)).andReturn(ImmutableList.of());
+
+    expect(rulesConfigReader.getRulesFromConfig(isA(Config.class)))
+        .andReturn(ImmutableList.of(rule1, rule2))
+        .andReturn(ImmutableList.of())
+        .anyTimes();
 
     replayMocks();
 
     RuleBase ruleBase = createRuleBase();
     Collection<ActionRequest> actual = ruleBase.actionRequestsFor(properties);
 
-    List<ActionRequest> expected = Lists.newArrayListWithCapacity(3);
-    expected.add(actionRequest1);
-    expected.add(actionRequest2);
-    expected.add(actionRequest3);
+    List<ActionRequest> expected = ImmutableList.of(actionRequest1, actionRequest2, actionRequest3);
 
     assertEquals("Matched actionRequests do not match", expected, actual);
   }
 
-  public void testWarnExistingFaultyNameRuleBaseFile() throws IOException {
-    injectRuleBase("", RuleBaseKind.FAULTY);
-
-    replayMocks();
-
-    createRuleBase();
-
-    assertLogMessageContains("Please migrate"); // Migration warning for old name
-    assertLogMessageContains("Neither global"); // For rule file at at usual places
-  }
-
-  public void testSimpleFaultyNameRuleBase() throws IOException {
-    injectRuleBase(
-        "[rule \"rule1\"]\n" + "\tconditionA = value1\n" + "\taction = action1",
-        RuleBaseKind.FAULTY);
-
-    Rule rule1 = createMock(Rule.class);
-    expect(ruleFactory.create("rule1")).andReturn(rule1);
-
-    Condition condition1 = createMock(Condition.class);
-    expect(conditionFactory.create("conditionA", "value1")).andReturn(condition1);
-    rule1.addCondition(condition1);
-
-    ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action1")).andReturn(actionRequest1);
-    rule1.addActionRequest(actionRequest1);
-
-    replayMocks();
-
-    createRuleBase();
-
-    assertLogMessageContains("Please migrate"); // Migration warning for old name
-    assertLogMessageContains("Neither global"); // For rule file at at usual places
-  }
-
-  public void testSimpleItsRuleBase() throws IOException {
-    injectRuleBase(
-        "[rule \"rule1\"]\n" + "\tconditionA = value1\n" + "\taction = action1", RuleBaseKind.ITS);
-
-    Rule rule1 = createMock(Rule.class);
-    expect(ruleFactory.create("rule1")).andReturn(rule1);
-
-    Condition condition1 = createMock(Condition.class);
-    expect(conditionFactory.create("conditionA", "value1")).andReturn(condition1);
-    rule1.addCondition(condition1);
-
-    ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action1")).andReturn(actionRequest1);
-    rule1.addActionRequest(actionRequest1);
-
-    replayMocks();
-
-    createRuleBase();
-  }
-
   public void testAllRuleBaseFilesAreLoaded() throws IOException {
-    injectRuleBase("[rule \"rule1\"]\n" + "\taction = action1", RuleBaseKind.FAULTY);
+    injectRuleBase("[rule \"rule2\"]\n\taction = action2", RuleBaseKind.GLOBAL);
 
-    injectRuleBase("[rule \"rule2\"]\n" + "\taction = action2", RuleBaseKind.GLOBAL);
+    injectRuleBase("[rule \"rule3\"]\n\taction = action3", RuleBaseKind.ITS);
 
-    injectRuleBase("[rule \"rule3\"]\n" + "\taction = action3", RuleBaseKind.ITS);
-
-    Collection<Property> properties = Collections.emptySet();
-
-    Rule rule1 = createMock(Rule.class);
-    expect(ruleFactory.create("rule1")).andReturn(rule1);
-
-    ActionRequest actionRequest1 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action1")).andReturn(actionRequest1);
-    rule1.addActionRequest(actionRequest1);
-
-    List<ActionRequest> rule1Match = Lists.newArrayListWithCapacity(1);
-    rule1Match.add(actionRequest1);
-    expect(rule1.actionRequestsFor(properties)).andReturn(rule1Match);
+    Map<String, String> properties = ImmutableMap.of(PROJECT_KEY, TEST_PROJECT);
 
     Rule rule2 = createMock(Rule.class);
-    expect(ruleFactory.create("rule2")).andReturn(rule2);
-
     ActionRequest actionRequest2 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action2")).andReturn(actionRequest2);
-    rule2.addActionRequest(actionRequest2);
 
-    List<ActionRequest> rule2Match = Lists.newArrayListWithCapacity(1);
-    rule2Match.add(actionRequest2);
+    List<ActionRequest> rule2Match = ImmutableList.of(actionRequest2);
     expect(rule2.actionRequestsFor(properties)).andReturn(rule2Match);
 
     Rule rule3 = createMock(Rule.class);
-    expect(ruleFactory.create("rule3")).andReturn(rule3);
-
     ActionRequest actionRequest3 = createMock(ActionRequest.class);
-    expect(actionRequestFactory.create("action3")).andReturn(actionRequest3);
-    rule3.addActionRequest(actionRequest3);
 
-    List<ActionRequest> rule3Match = Lists.newArrayListWithCapacity(1);
-    rule3Match.add(actionRequest3);
+    List<ActionRequest> rule3Match = ImmutableList.of(actionRequest3);
     expect(rule3.actionRequestsFor(properties)).andReturn(rule3Match);
 
+    expect(rulesProjectCache.get(TEST_PROJECT)).andReturn(ImmutableList.of());
+
+    expect(rulesConfigReader.getRulesFromConfig(isA(Config.class)))
+        .andReturn(ImmutableList.of(rule2, rule3))
+        .andReturn(ImmutableList.of())
+        .anyTimes();
+
     replayMocks();
 
     RuleBase ruleBase = createRuleBase();
 
     Collection<ActionRequest> actual = ruleBase.actionRequestsFor(properties);
 
-    List<ActionRequest> expected = Lists.newArrayListWithCapacity(3);
-    expected.add(actionRequest1);
-    expected.add(actionRequest2);
-    expected.add(actionRequest3);
+    List<ActionRequest> expected = ImmutableList.of(actionRequest2, actionRequest3);
 
     assertEquals("Matched actionRequests do not match", expected, actual);
+  }
 
-    assertLogMessageContains("Please migrate"); // Migration warning for old name
+  public void testProjectConfigIsLoaded() {
+    Rule rule1 = createMock(Rule.class);
+    ActionRequest actionRequest1 = createMock(ActionRequest.class);
+
+    Map<String, String> properties = ImmutableMap.of(PROJECT_KEY, TEST_PROJECT);
+
+    List<ActionRequest> rule1Match = ImmutableList.of(actionRequest1);
+    expect(rule1.actionRequestsFor(properties)).andReturn(rule1Match);
+
+    expect(rulesProjectCache.get(TEST_PROJECT)).andReturn(ImmutableList.of(rule1));
+
+    replayMocks();
+
+    RuleBase ruleBase = createRuleBase();
+    Collection<ActionRequest> actual = ruleBase.actionRequestsFor(properties);
+
+    List<ActionRequest> expected = ImmutableList.of(actionRequest1);
+
+    assertEquals("Matched actionRequests do not match", expected, actual);
   }
 
   private RuleBase createRuleBase() {
@@ -343,35 +200,9 @@
   }
 
   private void injectRuleBase(String rules, RuleBaseKind ruleBaseKind) throws IOException {
-    String baseName = "";
-    switch (ruleBaseKind) {
-      case GLOBAL:
-        baseName = "actions";
-        break;
-      case ITS:
-        baseName = "actions-ItsTestName";
-        break;
-      case FAULTY:
-        baseName = "action";
-        break;
-      default:
-        fail("Unknown ruleBaseKind");
-    }
-    File ruleBaseFile =
-        new File(
-            sitePath.toFile(),
-            "etc" + File.separatorChar + "its" + File.separator + baseName + ".config");
-
-    File ruleBaseParentFile = ruleBaseFile.getParentFile();
-    if (!ruleBaseParentFile.exists()) {
-      assertTrue(
-          "Failed to create parent (" + ruleBaseParentFile + ") for " + "rule base",
-          ruleBaseParentFile.mkdirs());
-    }
-    try (FileWriter unbufferedWriter = new FileWriter(ruleBaseFile);
-        BufferedWriter writer = new BufferedWriter(unbufferedWriter)) {
-      writer.write(rules);
-    }
+    Path ruleBaseFile = itsPath.resolve(ruleBaseKind.fileName);
+    Files.createDirectories(ruleBaseFile.getParent());
+    Files.write(ruleBaseFile, rules.getBytes());
   }
 
   @Override
@@ -384,8 +215,8 @@
   @Override
   public void tearDown() throws Exception {
     if (cleanupSitePath) {
-      if (Files.exists(sitePath)) {
-        FileUtils.delete(sitePath.toFile(), FileUtils.RECURSIVE);
+      if (Files.exists(itsPath)) {
+        FileUtils.delete(itsPath.toFile(), FileUtils.RECURSIVE);
       }
     }
     super.tearDown();
@@ -401,20 +232,25 @@
 
       bind(String.class).annotatedWith(PluginName.class).toInstance("ItsTestName");
 
-      sitePath = randomTargetPath();
-      assertFalse("sitePath already (" + sitePath + ") already exists", Files.exists(sitePath));
+      itsPath = randomTargetPath().resolve("etc").resolve("its");
+      assertFalse("itsPath (" + itsPath + ") already exists", Files.exists(itsPath));
       cleanupSitePath = true;
 
-      bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
+      bind(Path.class).annotatedWith(ItsPath.class).toInstance(itsPath);
 
-      ruleFactory = createMock(Rule.Factory.class);
-      bind(Rule.Factory.class).toInstance(ruleFactory);
+      rulesConfigReader = createMock(RulesConfigReader.class);
+      bind(RulesConfigReader.class).toInstance(rulesConfigReader);
 
-      conditionFactory = createMock(Condition.Factory.class);
-      bind(Condition.Factory.class).toInstance(conditionFactory);
+      rulesProjectCache = createMock(ItsRulesProjectCache.class);
+      bind(ItsRulesProjectCache.class).toInstance(rulesProjectCache);
 
-      actionRequestFactory = createMock(ActionRequest.Factory.class);
-      bind(ActionRequest.Factory.class).toInstance(actionRequestFactory);
+      bind(String.class)
+          .annotatedWith(GlobalRulesFileName.class)
+          .toInstance(RuleBaseKind.GLOBAL.fileName);
+
+      bind(String.class)
+          .annotatedWith(PluginRulesFileName.class)
+          .toInstance(RuleBaseKind.ITS.fileName);
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleTest.java
index 6d4d234..c106a81 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RuleTest.java
@@ -15,6 +15,7 @@
 
 import static org.easymock.EasyMock.expect;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.inject.Guice;
@@ -23,6 +24,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 public class RuleTest extends LoggingMockingTestCase {
   private Injector injector;
@@ -33,7 +35,7 @@
   }
 
   public void testActionsForUnconditionalRule() {
-    Collection<Property> properties = Collections.emptySet();
+    Map<String, String> properties = ImmutableMap.of();
 
     Rule rule = createRule("testRule");
 
@@ -50,7 +52,7 @@
   }
 
   public void testActionRequestsForConditionalRuleEmptyProperties() {
-    Collection<Property> properties = Collections.emptySet();
+    Map<String, String> properties = ImmutableMap.of();
 
     Rule rule = createRule("testRule");
 
@@ -70,7 +72,7 @@
   }
 
   public void testActionRequestsForConditionalRules() {
-    Collection<Property> properties = Collections.emptySet();
+    Map<String, String> properties = ImmutableMap.of();
 
     Rule rule = createRule("testRule");
 
@@ -94,7 +96,7 @@
   }
 
   public void testActionRequestsForMultipleActionRequests() {
-    Collection<Property> properties = Collections.emptySet();
+    Map<String, String> properties = ImmutableMap.of();
 
     Rule rule = createRule("testRule");
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RulesConfigReaderTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RulesConfigReaderTest.java
new file mode 100644
index 0000000..96fb869
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/RulesConfigReaderTest.java
@@ -0,0 +1,84 @@
+// 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.its.base.workflow;
+
+import static com.googlesource.gerrit.plugins.its.base.workflow.RulesConfigReader.ACTION_KEY;
+import static com.googlesource.gerrit.plugins.its.base.workflow.RulesConfigReader.RULE_SECTION;
+import static org.easymock.EasyMock.expect;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
+import java.util.Collection;
+import org.eclipse.jgit.lib.Config;
+
+public class RulesConfigReaderTest extends LoggingMockingTestCase {
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      ruleFactory = createMock(Rule.Factory.class);
+      bind(Rule.Factory.class).toInstance(ruleFactory);
+
+      conditionFactory = createMock(Condition.Factory.class);
+      bind(Condition.Factory.class).toInstance(conditionFactory);
+
+      actionRequestFactory = createMock(ActionRequest.Factory.class);
+      bind(ActionRequest.Factory.class).toInstance(actionRequestFactory);
+    }
+  }
+
+  private static final String ACTION_1 = "action1";
+  private static final String CONDITION_KEY = "condition";
+  private static final String RULE_1 = "rule1";
+  private static final String VALUE_1 = "value1";
+
+  private ActionRequest.Factory actionRequestFactory;
+  private Condition.Factory conditionFactory;
+  private Rule.Factory ruleFactory;
+  private Injector injector;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  public void testGetRulesFromConfig() {
+    Config cfg = new Config();
+    cfg.setString(RULE_SECTION, RULE_1, CONDITION_KEY, VALUE_1);
+    cfg.setString(RULE_SECTION, RULE_1, ACTION_KEY, ACTION_1);
+
+    Rule rule1 = createMock(Rule.class);
+    expect(ruleFactory.create(RULE_1)).andReturn(rule1);
+
+    ActionRequest actionRequest1 = createMock(ActionRequest.class);
+    expect(actionRequestFactory.create(ACTION_1)).andReturn(actionRequest1);
+    rule1.addActionRequest(actionRequest1);
+
+    Condition condition1 = createMock(Condition.class);
+    expect(conditionFactory.create(CONDITION_KEY, VALUE_1)).andReturn(condition1);
+    rule1.addCondition(condition1);
+
+    replayMocks();
+
+    Collection<Rule> expected = ImmutableList.of(rule1);
+
+    RulesConfigReader rulesConfigReader = injector.getInstance(RulesConfigReader.class);
+    assertEquals("Rules do not match", expected, rulesConfigReader.getRulesFromConfig(cfg));
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddStandardCommentTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddStandardCommentTest.java
deleted file mode 100644
index cfcda01..0000000
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddStandardCommentTest.java
+++ /dev/null
@@ -1,313 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.googlesource.gerrit.plugins.its.base.workflow.action;
-
-import static org.easymock.EasyMock.expect;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import java.io.IOException;
-import java.util.Set;
-
-public class AddStandardCommentTest extends LoggingMockingTestCase {
-  private Injector injector;
-
-  private ItsFacade its;
-
-  public void testChangeMergedPlain() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("change-merged").anyTimes();
-    properties.add(propertyEventType);
-
-    its.addComment("42", "Change merged");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("42", actionRequest, properties);
-  }
-
-  public void testChangeMergedFull() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("change-merged").anyTimes();
-    properties.add(propertyEventType);
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Test-Change-Subject").anyTimes();
-    properties.add(propertySubject);
-
-    Property propertyChangeNumber = createMock(Property.class);
-    expect(propertyChangeNumber.getKey()).andReturn("changeNumber").anyTimes();
-    expect(propertyChangeNumber.getValue()).andReturn("4711").anyTimes();
-    properties.add(propertyChangeNumber);
-
-    Property propertySubmitterName = createMock(Property.class);
-    expect(propertySubmitterName.getKey()).andReturn("submitterName").anyTimes();
-    expect(propertySubmitterName.getValue()).andReturn("John Doe").anyTimes();
-    properties.add(propertySubmitterName);
-
-    Property propertyChangeUrl = createMock(Property.class);
-    expect(propertyChangeUrl.getKey()).andReturn("changeUrl").anyTimes();
-    expect(propertyChangeUrl.getValue()).andReturn("http://example.org/change").anyTimes();
-    properties.add(propertyChangeUrl);
-
-    expect(its.createLinkForWebui("http://example.org/change", "http://example.org/change"))
-        .andReturn("HtTp://ExAmPlE.OrG/ChAnGe");
-
-    its.addComment(
-        "176",
-        "Change 4711 merged by John Doe:\n"
-            + "Test-Change-Subject\n"
-            + "\n"
-            + "HtTp://ExAmPlE.OrG/ChAnGe");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("176", actionRequest, properties);
-  }
-
-  public void testChangeAbandonedPlain() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("change-abandoned").anyTimes();
-    properties.add(propertyEventType);
-
-    its.addComment("42", "Change abandoned");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("42", actionRequest, properties);
-  }
-
-  public void testChangeAbandonedFull() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("change-abandoned").anyTimes();
-    properties.add(propertyEventType);
-
-    Property propertyReason = createMock(Property.class);
-    expect(propertyReason.getKey()).andReturn("reason").anyTimes();
-    expect(propertyReason.getValue()).andReturn("Test-Reason").anyTimes();
-    properties.add(propertyReason);
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Test-Change-Subject").anyTimes();
-    properties.add(propertySubject);
-
-    Property propertyChangeNumber = createMock(Property.class);
-    expect(propertyChangeNumber.getKey()).andReturn("changeNumber").anyTimes();
-    expect(propertyChangeNumber.getValue()).andReturn("4711").anyTimes();
-    properties.add(propertyChangeNumber);
-
-    Property propertySubmitterName = createMock(Property.class);
-    expect(propertySubmitterName.getKey()).andReturn("abandonerName").anyTimes();
-    expect(propertySubmitterName.getValue()).andReturn("John Doe").anyTimes();
-    properties.add(propertySubmitterName);
-
-    Property propertyChangeUrl = createMock(Property.class);
-    expect(propertyChangeUrl.getKey()).andReturn("changeUrl").anyTimes();
-    expect(propertyChangeUrl.getValue()).andReturn("http://example.org/change").anyTimes();
-    properties.add(propertyChangeUrl);
-
-    expect(its.createLinkForWebui("http://example.org/change", "http://example.org/change"))
-        .andReturn("HtTp://ExAmPlE.OrG/ChAnGe");
-
-    its.addComment(
-        "176",
-        "Change 4711 abandoned by John Doe:\n"
-            + "Test-Change-Subject\n"
-            + "\n"
-            + "Reason:\n"
-            + "Test-Reason\n"
-            + "\n"
-            + "HtTp://ExAmPlE.OrG/ChAnGe");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("176", actionRequest, properties);
-  }
-
-  public void testChangeRestoredPlain() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("change-restored").anyTimes();
-    properties.add(propertyEventType);
-
-    its.addComment("42", "Change restored");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("42", actionRequest, properties);
-  }
-
-  public void testChangeRestoredFull() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("change-restored").anyTimes();
-    properties.add(propertyEventType);
-
-    Property propertyReason = createMock(Property.class);
-    expect(propertyReason.getKey()).andReturn("reason").anyTimes();
-    expect(propertyReason.getValue()).andReturn("Test-Reason").anyTimes();
-    properties.add(propertyReason);
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Test-Change-Subject").anyTimes();
-    properties.add(propertySubject);
-
-    Property propertyChangeNumber = createMock(Property.class);
-    expect(propertyChangeNumber.getKey()).andReturn("changeNumber").anyTimes();
-    expect(propertyChangeNumber.getValue()).andReturn("4711").anyTimes();
-    properties.add(propertyChangeNumber);
-
-    Property propertySubmitterName = createMock(Property.class);
-    expect(propertySubmitterName.getKey()).andReturn("restorerName").anyTimes();
-    expect(propertySubmitterName.getValue()).andReturn("John Doe").anyTimes();
-    properties.add(propertySubmitterName);
-
-    Property propertyChangeUrl = createMock(Property.class);
-    expect(propertyChangeUrl.getKey()).andReturn("changeUrl").anyTimes();
-    expect(propertyChangeUrl.getValue()).andReturn("http://example.org/change").anyTimes();
-    properties.add(propertyChangeUrl);
-
-    expect(its.createLinkForWebui("http://example.org/change", "http://example.org/change"))
-        .andReturn("HtTp://ExAmPlE.OrG/ChAnGe");
-
-    its.addComment(
-        "176",
-        "Change 4711 restored by John Doe:\n"
-            + "Test-Change-Subject\n"
-            + "\n"
-            + "Reason:\n"
-            + "Test-Reason\n"
-            + "\n"
-            + "HtTp://ExAmPlE.OrG/ChAnGe");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("176", actionRequest, properties);
-  }
-
-  public void testPatchSetCreatedPlain() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("patchset-created").anyTimes();
-    properties.add(propertyEventType);
-
-    its.addComment("42", "Change had a related patch set uploaded");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("42", actionRequest, properties);
-  }
-
-  public void testPatchSetCreatedFull() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertyEventType = createMock(Property.class);
-    expect(propertyEventType.getKey()).andReturn("event-type").anyTimes();
-    expect(propertyEventType.getValue()).andReturn("patchset-created").anyTimes();
-    properties.add(propertyEventType);
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Test-Change-Subject").anyTimes();
-    properties.add(propertySubject);
-
-    Property propertyChangeNumber = createMock(Property.class);
-    expect(propertyChangeNumber.getKey()).andReturn("changeNumber").anyTimes();
-    expect(propertyChangeNumber.getValue()).andReturn("4711").anyTimes();
-    properties.add(propertyChangeNumber);
-
-    Property propertySubmitterName = createMock(Property.class);
-    expect(propertySubmitterName.getKey()).andReturn("uploaderName").anyTimes();
-    expect(propertySubmitterName.getValue()).andReturn("John Doe").anyTimes();
-    properties.add(propertySubmitterName);
-
-    Property propertyChangeUrl = createMock(Property.class);
-    expect(propertyChangeUrl.getKey()).andReturn("changeUrl").anyTimes();
-    expect(propertyChangeUrl.getValue()).andReturn("http://example.org/change").anyTimes();
-    properties.add(propertyChangeUrl);
-
-    expect(its.createLinkForWebui("http://example.org/change", "http://example.org/change"))
-        .andReturn("HtTp://ExAmPlE.OrG/ChAnGe");
-
-    its.addComment(
-        "176",
-        "Change 4711 had a related patch set uploaded by "
-            + "John Doe:\n"
-            + "Test-Change-Subject\n"
-            + "\n"
-            + "HtTp://ExAmPlE.OrG/ChAnGe");
-    replayMocks();
-
-    Action action = injector.getInstance(AddStandardComment.class);
-    action.execute("176", actionRequest, properties);
-  }
-
-  @Override
-  public void setUp() throws Exception {
-    super.setUp();
-
-    injector = Guice.createInjector(new TestModule());
-  }
-
-  private class TestModule extends FactoryModule {
-    @Override
-    protected void configure() {
-      its = createMock(ItsFacade.class);
-      bind(ItsFacade.class).toInstance(its);
-    }
-  }
-}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddVelocityCommentTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddVelocityCommentTest.java
deleted file mode 100644
index 61042a4..0000000
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/AddVelocityCommentTest.java
+++ /dev/null
@@ -1,450 +0,0 @@
-// Copyright (C) 2013 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.its.base.workflow.action;
-
-import static org.easymock.EasyMock.anyObject;
-import static org.easymock.EasyMock.capture;
-import static org.easymock.EasyMock.eq;
-import static org.easymock.EasyMock.expect;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.config.SitePath;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
-import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import com.googlesource.gerrit.plugins.its.base.workflow.action.AddVelocityComment.VelocityAdapterItsFacade;
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.io.Writer;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.UUID;
-import org.apache.velocity.VelocityContext;
-import org.apache.velocity.runtime.RuntimeInstance;
-import org.easymock.Capture;
-import org.easymock.EasyMock;
-import org.easymock.IAnswer;
-import org.eclipse.jgit.util.FileUtils;
-
-public class AddVelocityCommentTest extends LoggingMockingTestCase {
-  private Injector injector;
-
-  private Path sitePath;
-  private ItsFacade its;
-  private RuntimeInstance velocityRuntime;
-
-  private boolean cleanupSitePath;
-
-  public void testWarnNoTemplateNameGiven() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("");
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, new HashSet<>());
-
-    assertLogMessageContains("No template name");
-  }
-
-  public void testInlinePlain() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("inline");
-    expect(actionRequest.getParameters()).andReturn(new String[] {"inline", "Simple-text"});
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Simple-text");
-    expect(
-            velocityRuntime.evaluate(
-                (VelocityContext) anyObject(),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("Simple-text")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Simple-text");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, new HashSet<>());
-  }
-
-  public void testInlineWithMultipleParameters() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("inline");
-    expect(actionRequest.getParameters()).andReturn(new String[] {"inline", "Param2", "Param3"});
-
-    Set<Property> properties = Sets.newHashSet();
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Param2 Param3");
-    expect(
-            velocityRuntime.evaluate(
-                (VelocityContext) anyObject(),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("Param2 Param3")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Param2 Param3");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, properties);
-  }
-
-  public void testInlineWithSingleProperty() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("inline");
-    expect(actionRequest.getParameters()).andReturn(new String[] {"inline", "${subject}"});
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Rosebud").anyTimes();
-    properties.add(propertySubject);
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Rosebud");
-    Capture<VelocityContext> contextCapture = createCapture();
-    expect(
-            velocityRuntime.evaluate(
-                capture(contextCapture),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("${subject}")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Rosebud");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, properties);
-
-    VelocityContext context = contextCapture.getValue();
-    assertEquals("Subject property of context did not match", "Rosebud", context.get("subject"));
-  }
-
-  public void testInlineWithUnusedProperty() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("inline");
-    expect(actionRequest.getParameters()).andReturn(new String[] {"inline", "Test"});
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Rosebud").anyTimes();
-    properties.add(propertySubject);
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Test");
-    expect(
-            velocityRuntime.evaluate(
-                (VelocityContext) anyObject(),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("Test")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Test");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, properties);
-  }
-
-  public void testInlineWithMultipleProperties() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("inline");
-    expect(actionRequest.getParameters())
-        .andReturn(new String[] {"inline", "${subject}", "${reason}", "${subject}"});
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Rosebud").anyTimes();
-    properties.add(propertySubject);
-
-    Property propertyReason = createMock(Property.class);
-    expect(propertyReason.getKey()).andReturn("reason").anyTimes();
-    expect(propertyReason.getValue()).andReturn("Life").anyTimes();
-    properties.add(propertyReason);
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Rosebud Life Rosebud");
-    Capture<VelocityContext> contextCapture = createCapture();
-    expect(
-            velocityRuntime.evaluate(
-                capture(contextCapture),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("${subject} ${reason} ${subject}")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Rosebud Life Rosebud");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, properties);
-
-    VelocityContext context = contextCapture.getValue();
-    assertEquals("Subject property of context did not match", "Rosebud", context.get("subject"));
-    assertEquals("Reason property of context did not match", "Life", context.get("reason"));
-  }
-
-  public void testItsWrapperFormatLink1Parameter()
-      throws IOException, SecurityException, IllegalArgumentException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("inline");
-    expect(actionRequest.getParameters()).andReturn(new String[] {"inline", "Simple-Text"});
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Simple-Text");
-    Capture<VelocityContext> contextCapture = createCapture();
-    expect(
-            velocityRuntime.evaluate(
-                capture(contextCapture),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("Simple-Text")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Simple-Text");
-
-    expect(its.createLinkForWebui("http://www.example.org/", "http://www.example.org/"))
-        .andReturn("Formatted Link");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, new HashSet<>());
-
-    VelocityContext context = contextCapture.getValue();
-    Object itsAdapterObj = context.get("its");
-    assertNotNull("its property is null", itsAdapterObj);
-    assertTrue(
-        "Its is not a VelocityAdapterItsFacade instance",
-        itsAdapterObj instanceof VelocityAdapterItsFacade);
-    VelocityAdapterItsFacade itsAdapter = (VelocityAdapterItsFacade) itsAdapterObj;
-    String formattedLink = itsAdapter.formatLink("http://www.example.org/");
-    assertEquals("Result of formatLink does not match", "Formatted Link", formattedLink);
-  }
-
-  public void testItsWrapperFormatLink2Parameters()
-      throws IOException, SecurityException, IllegalArgumentException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("inline");
-    expect(actionRequest.getParameters()).andReturn(new String[] {"inline", "Simple-Text"});
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Simple-Text");
-    Capture<VelocityContext> contextCapture = createCapture();
-    expect(
-            velocityRuntime.evaluate(
-                capture(contextCapture),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("Simple-Text")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Simple-Text");
-
-    expect(its.createLinkForWebui("http://www.example.org/", "Caption"))
-        .andReturn("Formatted Link");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, new HashSet<>());
-
-    VelocityContext context = contextCapture.getValue();
-    Object itsAdapterObj = context.get("its");
-    assertNotNull("its property is null", itsAdapterObj);
-    assertTrue(
-        "Its is not a VelocityAdapterItsFacade instance",
-        itsAdapterObj instanceof VelocityAdapterItsFacade);
-    VelocityAdapterItsFacade itsAdapter = (VelocityAdapterItsFacade) itsAdapterObj;
-    String formattedLink = itsAdapter.formatLink("http://www.example.org/", "Caption");
-    assertEquals("Result of formatLink does not match", "Formatted Link", formattedLink);
-  }
-
-  public void testWarnTemplateNotFound() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("non-existing-template");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, new HashSet<>());
-
-    assertLogMessageContains("non-existing-template");
-  }
-
-  public void testTemplateSimple() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("test-template");
-
-    injectTestTemplate("Simple Test Template");
-
-    IAnswer<Boolean> answer = new VelocityWriterFiller("Simple Test Template");
-    expect(
-            velocityRuntime.evaluate(
-                (VelocityContext) anyObject(),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq("Simple Test Template")))
-        .andAnswer(answer);
-
-    its.addComment("4711", "Simple Test Template");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, new HashSet<>());
-  }
-
-  public void testTemplateMultipleParametersAndProperties() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("test-template");
-
-    Set<Property> properties = Sets.newHashSet();
-
-    Property propertySubject = createMock(Property.class);
-    expect(propertySubject.getKey()).andReturn("subject").anyTimes();
-    expect(propertySubject.getValue()).andReturn("Rosebud").anyTimes();
-    properties.add(propertySubject);
-
-    Property propertyReason = createMock(Property.class);
-    expect(propertyReason.getKey()).andReturn("reason").anyTimes();
-    expect(propertyReason.getValue()).andReturn("Life").anyTimes();
-    properties.add(propertyReason);
-
-    injectTestTemplate(
-        "Test Template with subject: ${subject}.\n" + "${reason} is the reason for ${subject}.");
-
-    IAnswer<Boolean> answer =
-        new VelocityWriterFiller(
-            "Test Template with subject: Rosebud.\n" + "Life is the reason for Rosebud.");
-    Capture<VelocityContext> contextCapture = createCapture();
-    expect(
-            velocityRuntime.evaluate(
-                capture(contextCapture),
-                (Writer) anyObject(),
-                (String) anyObject(),
-                eq(
-                    "Test Template with subject: ${subject}.\n"
-                        + "${reason} is the reason for ${subject}.")))
-        .andAnswer(answer);
-
-    its.addComment(
-        "4711", "Test Template with subject: Rosebud.\n" + "Life is the reason for Rosebud.");
-
-    replayMocks();
-
-    AddVelocityComment addVelocityComment = createAddVelocityComment();
-    addVelocityComment.execute("4711", actionRequest, properties);
-
-    VelocityContext context = contextCapture.getValue();
-    assertEquals("Subject property of context did not match", "Rosebud", context.get("subject"));
-    assertEquals("Reason property of context did not match", "Life", context.get("reason"));
-  }
-
-  private AddVelocityComment createAddVelocityComment() {
-    return injector.getInstance(AddVelocityComment.class);
-  }
-
-  private void injectTestTemplate(String template) throws IOException {
-    File templateParentFile =
-        new File(
-            sitePath.toFile(), "etc" + File.separatorChar + "its" + File.separator + "templates");
-    assertTrue(
-        "Failed to create parent (" + templateParentFile + ") for " + "rule base",
-        templateParentFile.mkdirs());
-    File templateFile = new File(templateParentFile, "test-template.vm");
-
-    try (FileWriter unbufferedWriter = new FileWriter(templateFile);
-        BufferedWriter writer = new BufferedWriter(unbufferedWriter)) {
-      writer.write(template);
-    }
-  }
-
-  @Override
-  public void setUp() throws Exception {
-    super.setUp();
-    cleanupSitePath = false;
-    injector = Guice.createInjector(new TestModule());
-  }
-
-  @Override
-  public void tearDown() throws Exception {
-    if (cleanupSitePath) {
-      if (Files.exists(sitePath)) {
-        FileUtils.delete(sitePath.toFile(), FileUtils.RECURSIVE);
-      }
-    }
-    super.tearDown();
-  }
-
-  private Path randomTargetPath() {
-    return Paths.get("target", "random-name-" + UUID.randomUUID().toString());
-  }
-
-  private class TestModule extends FactoryModule {
-    @Override
-    protected void configure() {
-      sitePath = randomTargetPath();
-      assertFalse("sitePath already (" + sitePath + ") already exists", Files.exists(sitePath));
-      cleanupSitePath = true;
-
-      bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-
-      its = createMock(ItsFacade.class);
-      bind(ItsFacade.class).toInstance(its);
-
-      velocityRuntime = createMock(RuntimeInstance.class);
-      bind(RuntimeInstance.class).toInstance(velocityRuntime);
-    }
-  }
-
-  private class VelocityWriterFiller implements IAnswer<Boolean> {
-    private final String fill;
-    private final boolean returnValue;
-
-    private VelocityWriterFiller(String fill, boolean returnValue) {
-      this.fill = fill;
-      this.returnValue = returnValue;
-    }
-
-    private VelocityWriterFiller(String fill) {
-      this(fill, true);
-    }
-
-    @Override
-    public Boolean answer() throws Throwable {
-      Object[] arguments = EasyMock.getCurrentArguments();
-      Writer writer = (Writer) arguments[1];
-      writer.write(fill);
-      return returnValue;
-    }
-  }
-}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/LogEventTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/LogEventTest.java
deleted file mode 100644
index 99fa2c3..0000000
--- a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/action/LogEventTest.java
+++ /dev/null
@@ -1,159 +0,0 @@
-// Copyright (C) 2013 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.its.base.workflow.action;
-
-import static org.easymock.EasyMock.expect;
-
-import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
-import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
-import com.googlesource.gerrit.plugins.its.base.workflow.Property;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import org.apache.log4j.Level;
-
-public class LogEventTest extends LoggingMockingTestCase {
-  private Injector injector;
-
-  public void testEmpty() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("");
-
-    replayMocks();
-
-    LogEvent logEvent = createLogEvent();
-    logEvent.execute("4711", actionRequest, new HashSet<>());
-  }
-
-  public void testLevelDefault() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("");
-
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(new PropertyMock("KeyA", "ValueA", "PropertyA"));
-    replayMocks();
-
-    LogEvent logEvent = createLogEvent();
-    logEvent.execute("4711", actionRequest, properties);
-
-    assertLogMessageContains("PropertyA", Level.INFO);
-  }
-
-  public void testLevelError() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("error");
-
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(new PropertyMock("KeyA", "ValueA", "PropertyA"));
-    replayMocks();
-
-    LogEvent logEvent = createLogEvent();
-    logEvent.execute("4711", actionRequest, properties);
-
-    assertLogMessageContains("PropertyA", Level.ERROR);
-  }
-
-  public void testLevelWarn() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("warn");
-
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(new PropertyMock("KeyA", "ValueA", "PropertyA"));
-    replayMocks();
-
-    LogEvent logEvent = createLogEvent();
-    logEvent.execute("4711", actionRequest, properties);
-
-    assertLogMessageContains("PropertyA", Level.WARN);
-  }
-
-  public void testLevelInfo() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("info");
-
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(new PropertyMock("KeyA", "ValueA", "PropertyA"));
-    replayMocks();
-
-    LogEvent logEvent = createLogEvent();
-    logEvent.execute("4711", actionRequest, properties);
-
-    assertLogMessageContains("PropertyA", Level.INFO);
-  }
-
-  public void testLevelDebug() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("debug");
-
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(new PropertyMock("KeyA", "ValueA", "PropertyA"));
-    replayMocks();
-
-    LogEvent logEvent = createLogEvent();
-    logEvent.execute("4711", actionRequest, properties);
-
-    assertLogMessageContains("PropertyA", Level.DEBUG);
-  }
-
-  public void testMultipleProperties() throws IOException {
-    ActionRequest actionRequest = createMock(ActionRequest.class);
-    expect(actionRequest.getParameter(1)).andReturn("info");
-
-    Set<Property> properties = Sets.newHashSet();
-    properties.add(new PropertyMock("KeyA", "ValueA", "PropertyA"));
-    properties.add(new PropertyMock("KeyB", "ValueB", "PropertyB"));
-    properties.add(new PropertyMock("KeyC", "ValueC", "PropertyC"));
-    replayMocks();
-
-    LogEvent logEvent = createLogEvent();
-    logEvent.execute("4711", actionRequest, properties);
-
-    assertLogMessageContains("PropertyA", Level.INFO);
-    assertLogMessageContains("PropertyB", Level.INFO);
-    assertLogMessageContains("PropertyC", Level.INFO);
-  }
-
-  private LogEvent createLogEvent() {
-    return injector.getInstance(LogEvent.class);
-  }
-
-  @Override
-  public void setUp() throws Exception {
-    super.setUp();
-    injector = Guice.createInjector(new TestModule());
-  }
-
-  private class TestModule extends FactoryModule {
-    @Override
-    protected void configure() {}
-  }
-
-  private class PropertyMock extends Property {
-    private final String toString;
-
-    public PropertyMock(String key, String value, String toString) {
-      super(key, value);
-      this.toString = toString;
-    }
-
-    @Override
-    public String toString() {
-      return toString;
-    }
-  }
-}