Merge branch 'stable-3.1'

* stable-3.1:
  Make tests that rely on logging work in Eclipse
  Make IssueExtractorTest more compact
  Allow to assert log messages multiple times in one go
  On test failure, dump logged but not yet asserted messages

Change-Id: Idbbd7e5b32b106e12a336fc4d08612abfd72791a
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/its/ItsConfig.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfig.java
index 9dda01f..af5e9a9 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
@@ -93,15 +93,16 @@
   }
 
   public boolean isEnabled(Project.NameKey projectNK, String refName) {
-    ProjectState projectState = projectCache.get(projectNK);
-    if (projectState == null) {
+    Optional<ProjectState> projectState = projectCache.get(projectNK);
+    if (!projectState.isPresent()) {
       logger.atSevere().log(
           "Failed to check if %s is enabled for project %s: Project not found",
           pluginName, projectNK.get());
       return false;
     }
-    return isEnforcedByAnyParentProject(refName, projectState)
-        || (isEnabledForProject(projectState) && isEnabledForBranch(projectState, refName));
+    return isEnforcedByAnyParentProject(refName, projectState.get())
+        || (isEnabledForProject(projectState.get())
+            && isEnabledForBranch(projectState.get(), refName));
   }
 
   private boolean isEnforcedByAnyParentProject(String refName, ProjectState projectState) {
@@ -145,12 +146,14 @@
 
   // Project association
   public Optional<String> getItsProjectName(Project.NameKey projectNK) {
-    ProjectState projectState = projectCache.get(projectNK);
-    if (projectState == null) {
+    Optional<ProjectState> projectState = projectCache.get(projectNK);
+    if (!projectState.isPresent()) {
       return Optional.empty();
     }
     return Optional.ofNullable(
-        pluginCfgFactory.getFromProjectConfig(projectState, pluginName).getString("its-project"));
+        pluginCfgFactory
+            .getFromProjectConfig(projectState.get(), pluginName)
+            .getString("its-project"));
   }
 
   // Issue association --------------------------------------------------------
@@ -265,7 +268,7 @@
   private List<CommentLinkInfo> getCommentLinkInfo(final String commentlinkName) {
     NameKey projectName = currentProjectName.get();
     if (projectName != null) {
-      List<CommentLinkInfo> commentlinks = projectCache.get(projectName).getCommentLinks();
+      List<CommentLinkInfo> commentlinks = projectCache.get(projectName).get().getCommentLinks();
       return commentlinks.stream()
           .filter(input -> input.name.equals(commentlinkName))
           .collect(toList());
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 9d6b2d9..4b8a7bd 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;
@@ -231,16 +232,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 3654e6f..789b7e8 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
@@ -35,6 +35,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
@@ -46,6 +47,7 @@
       LogEvent.Factory logEventFactory,
       AddPropertyToField.Factory addPropertyToFieldFactory,
       CreateVersionFromProperty.Factory createVersionFromPropertyFactory,
+      FireEventOnCommits.Factory fireEventOnCommitsFactory,
       DynamicMap<CustomAction> customActions) {
     this.itsFactory = itsFactory;
     this.addCommentFactory = addCommentFactory;
@@ -54,6 +56,7 @@
     this.logEventFactory = logEventFactory;
     this.addPropertyToFieldFactory = addPropertyToFieldFactory;
     this.createVersionFromPropertyFactory = createVersionFromPropertyFactory;
+    this.fireEventOnCommitsFactory = fireEventOnCommitsFactory;
     this.customActions = customActions;
   }
 
@@ -71,6 +74,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..7ade76b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/FireEventOnCommits.java
@@ -0,0 +1,89 @@
+// 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/ItsRulesProjectCacheImpl.java b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/ItsRulesProjectCacheImpl.java
index 9790f7d..e55a6dd 100644
--- 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
@@ -98,7 +98,10 @@
 
     @Override
     public List<Rule> load(String projectName) throws IOException {
-      ProjectState project = projectCache.checkedGet(Project.nameKey(projectName));
+      ProjectState project =
+          projectCache
+              .get(Project.nameKey(projectName))
+              .orElseThrow(() -> new IOException("Can't load " + projectName));
       List<Rule> projectRules = readRulesFrom(project);
       if (projectRules.isEmpty()) {
         for (ProjectState parent : project.parents()) {
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..d8f1ec1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/base/workflow/commit_collector/SinceLastTagCommitCollector.java
@@ -0,0 +1,97 @@
+// 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 e877e9b..95eecde 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
@@ -690,6 +697,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/its/ItsConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/base/its/ItsConfigTest.java
index 3379908..34068bb 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
@@ -56,8 +56,8 @@
       String enabled, String itsProject, String parentEnabled, String[] branches) {
     ProjectState projectState = mock(ProjectState.class);
 
-    when(projectCache.get(Project.nameKey("testProject"))).thenReturn(projectState);
-    when(projectCache.get(Project.nameKey("parentProject"))).thenReturn(projectState);
+    when(projectCache.get(Project.nameKey("testProject"))).thenReturn(Optional.of(projectState));
+    when(projectCache.get(Project.nameKey("parentProject"))).thenReturn(Optional.of(projectState));
 
     Iterable<ProjectState> parents;
     if (parentEnabled == null) {
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 bdc697b..3a575c7 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;
@@ -376,13 +377,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)
@@ -392,15 +410,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);
+  }
+}
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
index 6b83097..592722c 100644
--- 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
@@ -36,6 +36,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 
 public class ItsRulesProjectCacheTest extends LoggingMockingTestCase {
@@ -90,7 +91,7 @@
     ProjectLevelConfig projectLevelConfigPlugin = mock(ProjectLevelConfig.class);
     when(projectLevelConfigPlugin.get()).thenReturn(new Config());
     when(projectState.getConfig(RuleBaseKind.ITS.fileName)).thenReturn(projectLevelConfigPlugin);
-    when(projectCache.checkedGet(Project.nameKey(TEST_PROJECT))).thenReturn(projectState);
+    when(projectCache.get(Project.nameKey(TEST_PROJECT))).thenReturn(Optional.of(projectState));
     when(rulesConfigReader.getRulesFromConfig(any(Config.class)))
         .thenReturn(ImmutableList.of(rule1))
         .thenReturn(ImmutableList.of());
@@ -132,7 +133,7 @@
     when(parentProjectState.getConfig(RuleBaseKind.ITS.fileName))
         .thenReturn(parentProjectConfigPlugin);
     when(projectState.parents()).thenReturn(FluentIterable.of(parentProjectState));
-    when(projectCache.checkedGet(Project.nameKey(TEST_PROJECT))).thenReturn(projectState);
+    when(projectCache.get(Project.nameKey(TEST_PROJECT))).thenReturn(Optional.of(projectState));
 
     when(rulesConfigReader.getRulesFromConfig(any(Config.class)))
         .thenReturn(ImmutableList.of())