Fire event on past commits

The first use case of this change is when we want to generate
a release note when a new git tag is received by Gerrit.
The release note must contain the ITS issues fixed between the latest
tag and the previous tag.

In order to have reusable workflow, 'fire-event-on-commits' action is
introduced. This new action takes the name of collector as parameter.
A collector collects issues from past commits using an implementation
specific rule. The first collector introduced by this change is the
'since-last-tag' which allows to collect issues between the current HEAD
and the last previous git tag. The original Gerrit event will be fired
on each issue collected by the chosen collector.

Change-Id: Id920b13807d9913aa031da72671d1913acd9b3ef
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 dc6f4a5..8835389 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
@@ -38,9 +38,11 @@
 import com.googlesource.gerrit.plugins.its.base.workflow.Condition;
 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.FireEventOnCommits;
 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.commit_collector.SinceLastTagCommitCollector;
 import java.nio.file.Path;
 
 public class ItsHookModule extends FactoryModule {
@@ -78,6 +80,8 @@
     factory(AddPropertyToField.Factory.class);
     DynamicMap.mapOf(binder(), CustomAction.class);
     install(ItsRulesProjectCacheImpl.module());
+    factory(FireEventOnCommits.Factory.class);
+    factory(SinceLastTagCommitCollector.Factory.class);
   }
 
   @Provides
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 926db56..bf9dc0c 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
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.events.WorkInProgressStateChangedEvent;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.its.base.workflow.RefEventProperties;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
@@ -230,16 +231,33 @@
       associations = extractFrom((WorkInProgressStateChangedEvent) event, common);
     }
 
-    Set<Map<String, String>> ret = new HashSet<>();
-    if (associations != null) {
-      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);
-      }
+    Set<Map<String, String>> issuesProperties = extractIssuesProperties(common, associations);
+
+    Map<String, String> projectProperties = new HashMap<>(common);
+    projectProperties.put("source", "gerrit");
+    return new RefEventProperties(projectProperties, issuesProperties);
+  }
+
+  public Set<Map<String, String>> extractIssuesProperties(
+      Map<String, String> commonProperties, Map<String, Set<String>> associations) {
+    if (associations == null) {
+      return Collections.emptySet();
     }
-    return new RefEventProperties(common, ret);
+
+    Set<Map<String, String>> issuesProperties = new HashSet<>();
+    Map<String, String> completedCommonProperties = new HashMap<>(commonProperties);
+    if (!completedCommonProperties.containsKey("source")) {
+      completedCommonProperties.put("source", "its");
+    }
+
+    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(completedCommonProperties);
+      issuesProperties.add(properties);
+    }
+
+    return issuesProperties;
   }
 }
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 b8682ad..44f5d73 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
@@ -36,6 +36,7 @@
   private final LogEvent.Factory logEventFactory;
   private final AddPropertyToField.Factory addPropertyToFieldFactory;
   private final CreateVersionFromProperty.Factory createVersionFromPropertyFactory;
+  private final FireEventOnCommits.Factory fireEventOnCommitsFactory;
   private final DynamicMap<CustomAction> customActions;
 
   @Inject
@@ -47,6 +48,7 @@
       LogEvent.Factory logEventFactory,
       AddPropertyToField.Factory addPropertyToFieldFactory,
       CreateVersionFromProperty.Factory createVersionFromPropertyFactory,
+      FireEventOnCommits.Factory fireEventOnCommitsFactory,
       DynamicMap<CustomAction> customActions) {
     this.itsFactory = itsFactory;
     this.addCommentFactory = addCommentFactory;
@@ -55,6 +57,7 @@
     this.logEventFactory = logEventFactory;
     this.addPropertyToFieldFactory = addPropertyToFieldFactory;
     this.createVersionFromPropertyFactory = createVersionFromPropertyFactory;
+    this.fireEventOnCommitsFactory = fireEventOnCommitsFactory;
     this.customActions = customActions;
   }
 
@@ -72,6 +75,8 @@
         return addPropertyToFieldFactory.create();
       case "create-version-from-property":
         return createVersionFromPropertyFactory.create();
+      case "fire-event-on-commits":
+        return fireEventOnCommitsFactory.create();
       default:
         return customActions.get(PluginName.GERRIT, actionName);
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommits.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommits.java
new file mode 100644
index 0000000..259829d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommits.java
@@ -0,0 +1,91 @@
+// 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 com.googlesource.gerrit.plugins.its.base.util.IssueExtractor;
+import com.googlesource.gerrit.plugins.its.base.util.PropertyExtractor;
+import com.googlesource.gerrit.plugins.its.base.workflow.commit_collector.CommitCollector;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/** Fires the triggering event on collected past commits */
+public class FireEventOnCommits extends ProjectAction {
+
+  public interface Factory {
+    FireEventOnCommits create();
+  }
+
+  private final PropertyExtractor propertyExtractor;
+  private final IssueExtractor issueExtractor;
+  private final RuleBase ruleBase;
+  private final ActionExecutor actionExecutor;
+  private final FireEventOnCommitsParametersExtractor parametersExtractor;
+
+  @Inject
+  public FireEventOnCommits(
+      PropertyExtractor propertyExtractor,
+      IssueExtractor issueExtractor,
+      RuleBase ruleBase,
+      ActionExecutor actionExecutor,
+      FireEventOnCommitsParametersExtractor parametersExtractor) {
+    this.propertyExtractor = propertyExtractor;
+    this.issueExtractor = issueExtractor;
+    this.ruleBase = ruleBase;
+    this.actionExecutor = actionExecutor;
+    this.parametersExtractor = parametersExtractor;
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String itsProject, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    Optional<FireEventOnCommitsParameters> extractedParameters =
+        parametersExtractor.extract(actionRequest, properties);
+    if (!extractedParameters.isPresent()) {
+      return;
+    }
+
+    CommitCollector commitCollector = extractedParameters.get().getCommitCollector();
+    String projectName = extractedParameters.get().getProjectName();
+
+    Set<Map<String, String>> issuesProperties =
+        commitCollector
+            .collect(properties)
+            .stream()
+            .map(commitId -> issueExtractor.getIssueIds(projectName, commitId))
+            .flatMap(Stream::of)
+            .map(
+                associations -> propertyExtractor.extractIssuesProperties(properties, associations))
+            .flatMap(Collection::stream)
+            .collect(Collectors.toSet());
+
+    issuesProperties.forEach(this::doExecute);
+  }
+
+  private void doExecute(Map<String, String> issueProperties) {
+    Collection<ActionRequest> actions = ruleBase.actionRequestsFor(issueProperties);
+    if (actions.isEmpty()) {
+      return;
+    }
+    actionExecutor.executeOnIssue(actions, issueProperties);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParameters.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParameters.java
new file mode 100644
index 0000000..d6ee4db
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParameters.java
@@ -0,0 +1,41 @@
+// 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.googlesource.gerrit.plugins.its.base.workflow.commit_collector.CommitCollector;
+
+/** Parameters needed by {@link FireEventOnCommits} action */
+public class FireEventOnCommitsParameters {
+
+  private final CommitCollector commitCollector;
+  private final String projectName;
+
+  public FireEventOnCommitsParameters(CommitCollector commitCollector, String projectName) {
+    this.commitCollector = commitCollector;
+    this.projectName = projectName;
+  }
+
+  /**
+   * @return The collector that will be used to collect the commits on which the event will be fired
+   */
+  public CommitCollector getCommitCollector() {
+    return commitCollector;
+  }
+
+  /** @return The name of the project from which the commits will be collected */
+  public String getProjectName() {
+    return projectName;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParametersExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParametersExtractor.java
new file mode 100644
index 0000000..d7ae586
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParametersExtractor.java
@@ -0,0 +1,78 @@
+// 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.googlesource.gerrit.plugins.its.base.workflow.commit_collector.CommitCollector;
+import com.googlesource.gerrit.plugins.its.base.workflow.commit_collector.SinceLastTagCommitCollector;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class FireEventOnCommitsParametersExtractor {
+
+  private static final Logger log =
+      LoggerFactory.getLogger(FireEventOnCommitsParametersExtractor.class);
+
+  private final SinceLastTagCommitCollector.Factory sinceLastTagCommitCollectorFactory;
+
+  @Inject
+  public FireEventOnCommitsParametersExtractor(
+      SinceLastTagCommitCollector.Factory sinceLastTagCommitCollectorFactory) {
+    this.sinceLastTagCommitCollectorFactory = sinceLastTagCommitCollectorFactory;
+  }
+
+  private CommitCollector getCommitCollector(String name) {
+    switch (name) {
+      case "since-last-tag":
+        return sinceLastTagCommitCollectorFactory.create();
+      default:
+        return null;
+    }
+  }
+
+  /**
+   * @return The parameters needed by {@link FireEventOnCommits}. Empty if the parameters could not
+   *     be extracted.
+   */
+  public Optional<FireEventOnCommitsParameters> 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 collector name.",
+          Arrays.toString(parameters));
+      return Optional.empty();
+    }
+
+    String collectorName = parameters[0];
+    if (Strings.isNullOrEmpty(collectorName)) {
+      log.error("Received collector name is blank");
+      return Optional.empty();
+    }
+
+    CommitCollector commitCollector = getCommitCollector(collectorName);
+    if (commitCollector == null) {
+      log.error("No commit collector found for name {}", collectorName);
+      return Optional.empty();
+    }
+
+    String projectName = properties.get("project");
+    return Optional.of(new FireEventOnCommitsParameters(commitCollector, projectName));
+  }
+}
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
index 30940bb..01b6395 100644
--- 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
@@ -14,7 +14,6 @@
 
 package com.googlesource.gerrit.plugins.its.base.workflow;
 
-import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
 
@@ -31,8 +30,8 @@
    */
   public RefEventProperties(
       Map<String, String> projectProperties, Set<Map<String, String>> issuesProperties) {
-    this.projectProperties = Collections.unmodifiableMap(projectProperties);
-    this.issuesProperties = Collections.unmodifiableSet(issuesProperties);
+    this.projectProperties = projectProperties;
+    this.issuesProperties = issuesProperties;
   }
 
   /** @return Properties of the ref event */
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/commit_collector/CommitCollector.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/commit_collector/CommitCollector.java
new file mode 100644
index 0000000..73ea110
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/commit_collector/CommitCollector.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.commit_collector;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/** Collects commits based on the event properties provided in input */
+public interface CommitCollector {
+  List<String> collect(Map<String, String> properties) throws IOException;
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/commit_collector/SinceLastTagCommitCollector.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/commit_collector/SinceLastTagCommitCollector.java
new file mode 100644
index 0000000..dbe597d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/commit_collector/SinceLastTagCommitCollector.java
@@ -0,0 +1,99 @@
+// 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.commit_collector;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import javax.inject.Inject;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Collects all commits between the last tag and HEAD */
+public class SinceLastTagCommitCollector implements CommitCollector {
+
+  public interface Factory {
+    SinceLastTagCommitCollector create();
+  }
+
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  public SinceLastTagCommitCollector(GitRepositoryManager repoManager) {
+    this.repoManager = repoManager;
+  }
+
+  @Override
+  public List<String> collect(Map<String, String> properties) throws IOException {
+    String projectName = properties.get("project");
+    String revision = properties.get("revision");
+
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
+      return collect(repo, revision);
+    }
+  }
+
+  private List<String> collect(Repository repo, String currentRevision) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      RevCommit currentCommit = revWalk.parseCommit(repo.resolve(currentRevision));
+      revWalk.markStart(currentCommit);
+      List<String> commitIds = new ArrayList<>();
+      for (RevCommit commit : revWalk) {
+        if (!currentCommit.getId().equals(commit.getId()) && isTagged(repo, commit)) {
+          break;
+        }
+        commitIds.add(ObjectId.toString(commit.getId()));
+      }
+      return commitIds;
+    }
+  }
+
+  /** @return True if {@code commit} is tagged */
+  private boolean isTagged(Repository repo, RevCommit commit) throws IOException {
+    ObjectId commitId = commit.getId();
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      return repo.getRefDatabase()
+          .getRefsByPrefix(Constants.R_TAGS)
+          .stream()
+          .map(Ref::getObjectId)
+          .map(
+              refObjectId -> {
+                try {
+                  return revWalk.parseTag(refObjectId);
+                } catch (IncorrectObjectTypeException e) {
+                  return null;
+                } catch (IOException e) {
+                  throw new UncheckedIOException(e);
+                }
+              })
+          .filter(Objects::nonNull)
+          .map(RevTag::getObject)
+          .map(RevObject::getId)
+          .anyMatch(commitId::equals);
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/config-rulebase-common.md b/src/main/resources/Documentation/config-rulebase-common.md
index d232e57..50e1023 100644
--- a/src/main/resources/Documentation/config-rulebase-common.md
+++ b/src/main/resources/Documentation/config-rulebase-common.md
@@ -574,6 +574,13 @@
 `uploaderUsername`
 : username of the user that uploaded this patch set.
 
+### Common properties for all events
+
+`source`
+: source of the event that triggered the action. Can be `its` or `gerrit`.
+  `its` sourced events are fired by its actions. e.g. `fire-event-on-commits` action fires `its` sourced events.
+  `gerrit` sourced events are directly fired by the Gerrit event system.
+
 ## Actions
 
 Lines of the form
@@ -693,6 +700,26 @@
   action = create-version-from-property ref
 ```
 
+### Action: fire-event-on-commits
+
+The `fire-event-on-commits` action start by collecting commits using a collector then fire the event
+on each collected commit.
+
+This is useful when you want to trigger rules on a multiple past commits.
+
+Available collectors are:
+- `since-last-tag`: Collects all commits between the current ref and previous tag
+
+To avoid to trigger issue actions twice for the same event, you should condition your rule on
+the event property `source`.
+
+Example:
+
+```
+  action = fire-event-on-commits since-last-tag
+```
+
+
 ### Action: log-event
 
 The `log-event` action appends the event's properties to Gerrit's log.
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 ac7ea92..d4f0605 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
@@ -41,6 +41,7 @@
 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.RefEventProperties;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
@@ -374,13 +375,30 @@
       when(issueExtractor.getIssueIds("testProject", "testRevision")).thenReturn(issueMap);
     }
 
-    Set<Map<String, String>> actual = propertyExtractor.extractFrom(event).getIssuesProperties();
+    RefEventProperties refEventProperties = propertyExtractor.extractFrom(event);
+
+    Map<String, String> actualProjectProperties = refEventProperties.getProjectProperties();
+
+    Map<String, String> expectedProjectProperties =
+        ImmutableMap.<String, String>builder()
+            .put("itsName", "ItsTestName")
+            .put("event", "com.google.gerrit.server.events." + className)
+            .put("event-type", type)
+            .put("source", "gerrit")
+            .putAll(common)
+            .build();
+
+    assertEquals(
+        "Project properties do not match", expectedProjectProperties, actualProjectProperties);
+
+    Set<Map<String, String>> actualIssuesProperties = refEventProperties.getIssuesProperties();
 
     Map<String, String> propertiesIssue4711 =
         ImmutableMap.<String, String>builder()
             .put("itsName", "ItsTestName")
             .put("event", "com.google.gerrit.server.events." + className)
             .put("event-type", type)
+            .put("source", "its")
             .put("association", "body anywhere")
             .put("issue", "4711")
             .putAll(common)
@@ -390,15 +408,17 @@
             .put("itsName", "ItsTestName")
             .put("event", "com.google.gerrit.server.events." + className)
             .put("event-type", type)
+            .put("source", "its")
             .put("association", "anywhere footer")
             .put("issue", "42")
             .putAll(common)
             .build();
-    Set<Map<String, String>> expected = new HashSet<>();
-    expected.add(propertiesIssue4711);
-    expected.add(propertiesIssue42);
+    Set<Map<String, String>> expectedIssuesProperties = new HashSet<>();
+    expectedIssuesProperties.add(propertiesIssue4711);
+    expectedIssuesProperties.add(propertiesIssue42);
 
-    assertEquals("Properties do not match", expected, actual);
+    assertEquals(
+        "Issues properties do not match", expectedIssuesProperties, actualIssuesProperties);
   }
 
   @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 5b8c43f..822602b 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
@@ -48,6 +48,7 @@
   private LogEvent.Factory logEventFactory;
   private AddPropertyToField.Factory addPropertyToFieldFactory;
   private CreateVersionFromProperty.Factory createVersionFromPropertyFactory;
+  private FireEventOnCommits.Factory fireEventOnCommitsFactory;
   private CustomAction customAction;
 
   private Map<String, String> properties =
@@ -212,7 +213,8 @@
 
     CreateVersionFromProperty createVersionFromProperty = mock(CreateVersionFromProperty.class);
     when(createVersionFromPropertyFactory.create()).thenReturn(createVersionFromProperty);
-    when(itsFacadeFactory.getFacade(Project.nameKey(properties.get("project")))).thenReturn(its);
+    when(itsFacadeFactory.getFacade(Project.nameKey(projectProperties.get("project"))))
+        .thenReturn(its);
 
     ActionExecutor actionExecutor = createActionExecutor();
     actionExecutor.executeOnProject(Collections.singleton(actionRequest), projectProperties);
@@ -237,6 +239,21 @@
     verify(addPropertyToField).execute(its, "4711", actionRequest, properties);
   }
 
+  public void testFireEventOnCommitsDelegation() throws IOException {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getName()).thenReturn("fire-event-on-commits");
+
+    FireEventOnCommits fireEventOnCommits = mock(FireEventOnCommits.class);
+    when(fireEventOnCommitsFactory.create()).thenReturn(fireEventOnCommits);
+    when(itsFacadeFactory.getFacade(Project.nameKey(projectProperties.get("project"))))
+        .thenReturn(its);
+
+    ActionExecutor actionExecutor = createActionExecutor();
+    actionExecutor.executeOnProject(Collections.singleton(actionRequest), projectProperties);
+
+    verify(fireEventOnCommits).execute(its, "itsTestProject", actionRequest, projectProperties);
+  }
+
   public void testExecuteIssueCustomAction() throws IOException {
     when(customAction.getType()).thenReturn(ActionType.ISSUE);
 
@@ -304,6 +321,9 @@
       createVersionFromPropertyFactory = mock(CreateVersionFromProperty.Factory.class);
       bind(CreateVersionFromProperty.Factory.class).toInstance(createVersionFromPropertyFactory);
 
+      fireEventOnCommitsFactory = mock(FireEventOnCommits.Factory.class);
+      bind(FireEventOnCommits.Factory.class).toInstance(fireEventOnCommitsFactory);
+
       DynamicMap.mapOf(binder(), CustomAction.class);
       customAction = mock(CustomAction.class);
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParametersExtractorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParametersExtractorTest.java
new file mode 100644
index 0000000..c99bf33
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsParametersExtractorTest.java
@@ -0,0 +1,102 @@
+// 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.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.its.base.workflow.commit_collector.SinceLastTagCommitCollector;
+import java.util.Collections;
+import java.util.Optional;
+import junit.framework.TestCase;
+
+public class FireEventOnCommitsParametersExtractorTest extends TestCase {
+
+  private static final String SINCE_LAST_TAG_COLLECTOR = "since-last-tag";
+
+  private SinceLastTagCommitCollector.Factory sinceLastTagCommitCollectorFactory;
+  private FireEventOnCommitsParametersExtractor extractor;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    Injector injector = Guice.createInjector(new TestModule());
+    extractor = injector.getInstance(FireEventOnCommitsParametersExtractor.class);
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      sinceLastTagCommitCollectorFactory = mock(SinceLastTagCommitCollector.Factory.class);
+      bind(SinceLastTagCommitCollector.Factory.class)
+          .toInstance(sinceLastTagCommitCollectorFactory);
+    }
+  }
+
+  public void testNoParameter() {
+    testWrongNumberOfReceivedParameters(new String[] {});
+  }
+
+  public void testTwoParameters() {
+    testWrongNumberOfReceivedParameters(
+        new String[] {SINCE_LAST_TAG_COLLECTOR, SINCE_LAST_TAG_COLLECTOR});
+  }
+
+  private void testWrongNumberOfReceivedParameters(String[] parameters) {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(parameters);
+
+    Optional<FireEventOnCommitsParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  public void testBlankCollectorName() {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(new String[] {""});
+
+    Optional<FireEventOnCommitsParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  public void testUnknownCollectorName() {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(new String[] {"foo"});
+
+    Optional<FireEventOnCommitsParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  public void testSinceLastTagCollector() {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(new String[] {SINCE_LAST_TAG_COLLECTOR});
+
+    SinceLastTagCommitCollector collector = mock(SinceLastTagCommitCollector.class);
+    when(sinceLastTagCommitCollectorFactory.create()).thenReturn(collector);
+
+    Optional<FireEventOnCommitsParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.singletonMap("project", "testProject"));
+    if (!extractedParameters.isPresent()) {
+      fail();
+    }
+    assertEquals(collector, extractedParameters.get().getCommitCollector());
+    assertEquals("testProject", extractedParameters.get().getProjectName());
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsTest.java
new file mode 100644
index 0000000..e80081d
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommitsTest.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.its.base.workflow;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+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.util.IssueExtractor;
+import com.googlesource.gerrit.plugins.its.base.util.PropertyExtractor;
+import com.googlesource.gerrit.plugins.its.base.workflow.commit_collector.SinceLastTagCommitCollector;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import junit.framework.TestCase;
+
+public class FireEventOnCommitsTest extends TestCase {
+
+  private static final String ITS_PROJECT = "test-project";
+  private static final String COMMIT = "1234";
+  private static final String PROJECT = "testProject";
+
+  private Injector injector;
+  private ItsFacade its;
+  private PropertyExtractor propertyExtractor;
+  private IssueExtractor issueExtractor;
+  private RuleBase ruleBase;
+  private ActionExecutor actionExecutor;
+  private FireEventOnCommitsParametersExtractor parametersExtractor;
+
+  @Override
+  public void setUp() throws Exception {
+    super.setUp();
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      its = mock(ItsFacade.class);
+      bind(ItsFacade.class).toInstance(its);
+
+      propertyExtractor = mock(PropertyExtractor.class);
+      bind(PropertyExtractor.class).toInstance(propertyExtractor);
+
+      ruleBase = mock(RuleBase.class);
+      bind(RuleBase.class).toInstance(ruleBase);
+
+      issueExtractor = mock(IssueExtractor.class);
+      bind(IssueExtractor.class).toInstance(issueExtractor);
+
+      actionExecutor = mock(ActionExecutor.class);
+      bind(ActionExecutor.class).toInstance(actionExecutor);
+
+      parametersExtractor = mock(FireEventOnCommitsParametersExtractor.class);
+      bind(FireEventOnCommitsParametersExtractor.class).toInstance(parametersExtractor);
+    }
+  }
+
+  public void testHappyPath() throws IOException {
+    Map<String, String> properties = ImmutableMap.of("project", PROJECT);
+
+    FireEventOnCommitsParameters parameters = mock(FireEventOnCommitsParameters.class);
+    SinceLastTagCommitCollector collector = mock(SinceLastTagCommitCollector.class);
+    when(parameters.getCommitCollector()).thenReturn(collector);
+    when(parameters.getProjectName()).thenReturn(PROJECT);
+
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(parametersExtractor.extract(actionRequest, properties))
+        .thenReturn(Optional.of(parameters));
+    when(collector.collect(properties)).thenReturn(Collections.singletonList(COMMIT));
+
+    Map<String, Set<String>> associations = Maps.newHashMap();
+    when(issueExtractor.getIssueIds(PROJECT, COMMIT)).thenReturn(associations);
+
+    Set<Map<String, String>> issuesProperties = ImmutableSet.of(properties);
+    when(propertyExtractor.extractIssuesProperties(properties, associations))
+        .thenReturn(issuesProperties);
+
+    ActionRequest subActionRequest = mock(ActionRequest.class);
+    Collection<ActionRequest> subActionRequests = Collections.singleton(subActionRequest);
+    when(ruleBase.actionRequestsFor(properties)).thenReturn(subActionRequests);
+
+    FireEventOnCommits fireEventOnCommits = createFireEventOnCommits();
+    fireEventOnCommits.execute(its, ITS_PROJECT, actionRequest, properties);
+
+    verify(actionExecutor).executeOnIssue(subActionRequests, properties);
+  }
+
+  private FireEventOnCommits createFireEventOnCommits() {
+    return injector.getInstance(FireEventOnCommits.class);
+  }
+}