Merge "Add actions for comments built from Velocity templates"
diff --git a/hooks-its/pom.xml b/hooks-its/pom.xml
index 576530a..12c259b 100644
--- a/hooks-its/pom.xml
+++ b/hooks-its/pom.xml
@@ -37,6 +37,11 @@
       <version>${project.version}</version>
     </dependency>
     <dependency>
+      <groupId>org.apache.velocity</groupId>
+      <artifactId>velocity</artifactId>
+      <version>${velocityVersion}</version>
+    </dependency>
+    <dependency>
       <groupId>org.slf4j</groupId>
       <artifactId>slf4j-log4j12</artifactId>
       <version>${slf4jVersion}</version>
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java
index 9e18eef..16acf53 100644
--- a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java
+++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/ItsHookModule.java
@@ -30,6 +30,7 @@
 import com.googlesource.gerrit.plugins.hooks.workflow.Property;
 import com.googlesource.gerrit.plugins.hooks.workflow.Rule;
 import com.googlesource.gerrit.plugins.hooks.workflow.action.AddStandardComment;
+import com.googlesource.gerrit.plugins.hooks.workflow.action.AddVelocityComment;
 
 public class ItsHookModule extends FactoryModule {
 
@@ -59,5 +60,6 @@
     factory(Condition.Factory.class);
     factory(Rule.Factory.class);
     factory(AddStandardComment.Factory.class);
+    factory(AddVelocityComment.Factory.class);
   }
 }
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutor.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutor.java
index 4e99423..64fd014 100644
--- a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutor.java
+++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutor.java
@@ -24,6 +24,7 @@
 import com.googlesource.gerrit.plugins.hooks.its.ItsFacade;
 import com.googlesource.gerrit.plugins.hooks.workflow.action.Action;
 import com.googlesource.gerrit.plugins.hooks.workflow.action.AddStandardComment;
+import com.googlesource.gerrit.plugins.hooks.workflow.action.AddVelocityComment;
 
 /**
  * Executes an {@link ActionRequest}
@@ -34,12 +35,15 @@
 
   private final ItsFacade its;
   private final AddStandardComment.Factory addStandardCommentFactory;
+  private final AddVelocityComment.Factory addVelocityCommentFactory;
 
   @Inject
   public ActionExecutor(ItsFacade its,
-      AddStandardComment.Factory addStandardCommentFactory) {
+      AddStandardComment.Factory addStandardCommentFactory,
+      AddVelocityComment.Factory addVelocityCommentFactory) {
     this.its = its;
     this.addStandardCommentFactory = addStandardCommentFactory;
+    this.addVelocityCommentFactory = addVelocityCommentFactory;
   }
 
   public void execute(String issue, ActionRequest actionRequest,
@@ -49,6 +53,8 @@
       Action action = null;
       if ("add-standard-comment".equals(name)) {
         action = addStandardCommentFactory.create();
+      } else if ("add-velocity-comment".equals(name)) {
+        action = addVelocityCommentFactory.create();
       }
 
       if (action == null) {
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequest.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequest.java
index cb3e5f3..c829569 100644
--- a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequest.java
+++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequest.java
@@ -14,6 +14,8 @@
 
 package com.googlesource.gerrit.plugins.hooks.workflow;
 
+import java.util.Arrays;
+
 import javax.annotation.Nullable;
 
 import com.google.inject.Inject;
@@ -58,6 +60,30 @@
   }
 
   /**
+   * Gets the name of the requested action.
+   *
+   * @param i The number of the parameter to extract. 1 is the first parameter.
+   * @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;
+  }
+
+  /**
+   * Gets the parameters of the requested action.
+   *
+   * @return The parameters of the requested action.
+   */
+  public String[] getParameters() {
+    return Arrays.copyOfRange(chopped, 1, chopped.length);
+  }
+
+  /**
    * Gets the unparsed specification of this action request.
    *
    * @return The unparsed action request.
diff --git a/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/action/AddVelocityComment.java b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/action/AddVelocityComment.java
new file mode 100644
index 0000000..45afb65
--- /dev/null
+++ b/hooks-its/src/main/java/com/googlesource/gerrit/plugins/hooks/workflow/action/AddVelocityComment.java
@@ -0,0 +1,117 @@
+// 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.hooks.workflow.action;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.StringWriter;
+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.parboiled.common.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.server.config.SitePath;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.hooks.its.ItsFacade;
+import com.googlesource.gerrit.plugins.hooks.workflow.ActionRequest;
+import com.googlesource.gerrit.plugins.hooks.workflow.Property;
+
+/**
+ * Adds a short predefined comments to an issue.
+ *
+ * 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 File sitePath;
+  private final RuntimeInstance velocityRuntime;
+
+  @Inject
+  public AddVelocityComment(RuntimeInstance velocityRuntime, @SitePath File 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);
+        }
+      }
+    }
+    return velocityContext;
+  }
+
+  private String velocify(String template, Set<Property> properties) throws IOException {
+    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 {
+        File templateFile = new File(sitePath, ITS_TEMPLATE_DIR +
+            File.separator + templateName + ".vm");
+        if (templateFile.canRead()) {
+          template = FileUtils.readAllText(templateFile);
+        } else {
+          log.error("Cannot read template " + templateFile);
+        }
+      }
+    }
+    if (!Strings.isNullOrEmpty(template)) {
+      String comment = velocify(template, properties);
+      its.addComment(issue, comment);
+    }
+  }
+}
diff --git a/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutorTest.java b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutorTest.java
index a243812..9974967 100644
--- a/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutorTest.java
+++ b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionExecutorTest.java
@@ -27,12 +27,14 @@
 import com.googlesource.gerrit.plugins.hooks.its.ItsFacade;
 import com.googlesource.gerrit.plugins.hooks.testutil.LoggingMockingTestCase;
 import com.googlesource.gerrit.plugins.hooks.workflow.action.AddStandardComment;
+import com.googlesource.gerrit.plugins.hooks.workflow.action.AddVelocityComment;
 
 public class ActionExecutorTest extends LoggingMockingTestCase {
   private Injector injector;
 
   private ItsFacade its;
   private AddStandardComment.Factory addStandardCommentFactory;
+  private AddVelocityComment.Factory addVelocityCommentFactory;
 
   public void testExecuteItem() throws IOException {
     ActionRequest actionRequest = createMock(ActionRequest.class);
@@ -119,6 +121,42 @@
     assertLogThrowableMessageContains("injected exception 3");
   }
 
+  public void testAddStandardCommentDelegation() throws IOException {
+    ActionRequest actionRequest = createMock(ActionRequest.class);
+    expect(actionRequest.getName()).andReturn("add-standard-comment");
+
+    Set<Property> properties = Collections.emptySet();
+
+    AddStandardComment addStandardComment =
+        createMock(AddStandardComment.class);
+    expect(addStandardCommentFactory.create()).andReturn(addStandardComment);
+
+    addStandardComment.execute("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);
+  }
+
   private ActionExecutor createActionExecutor() {
     return injector.getInstance(ActionExecutor.class);
   }
@@ -137,6 +175,10 @@
       addStandardCommentFactory = createMock(AddStandardComment.Factory.class);
       bind(AddStandardComment.Factory.class).toInstance(
           addStandardCommentFactory);
+
+      addVelocityCommentFactory = createMock(AddVelocityComment.Factory.class);
+      bind(AddVelocityComment.Factory.class).toInstance(
+          addVelocityCommentFactory);
     }
   }
 }
\ No newline at end of file
diff --git a/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequestTest.java b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequestTest.java
index 415a4ec..f7b160d 100644
--- a/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequestTest.java
+++ b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/ActionRequestTest.java
@@ -14,6 +14,7 @@
 package com.googlesource.gerrit.plugins.hooks.workflow;
 
 import java.io.IOException;
+import java.util.Arrays;
 
 import com.google.gerrit.server.config.FactoryModule;
 import com.google.inject.Guice;
@@ -87,6 +88,129 @@
         actionRequest.getName());
   }
 
+  public void testParameter1Parameterless() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action");
+    assertEquals("Unparsed string does not match", "",
+        actionRequest.getParameter(1));
+  }
+
+  public void testParameter1Null() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest(null);
+    assertEquals("Unparsed string does not match", "",
+        actionRequest.getParameter(1));
+  }
+
+  public void testParameter1SingleParameter() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param");
+    assertEquals("Unparsed string does not match", "param",
+        actionRequest.getParameter(1));
+  }
+
+  public void testParemeter1MultipleParameters() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param1 param2");
+    assertEquals("Unparsed string does not match", "param1",
+        actionRequest.getParameter(1));
+  }
+
+  public void testParameter3Parameterless() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action");
+    assertEquals("Unparsed string does not match", "",
+        actionRequest.getParameter(3));
+  }
+
+  public void testParameter3Null() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest(null);
+    assertEquals("Unparsed string does not match", "",
+        actionRequest.getParameter(3));
+  }
+
+  public void testParameter3SingleParameter() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param");
+    assertEquals("Unparsed string does not match", "",
+        actionRequest.getParameter(3));
+  }
+
+  public void testParemeter3With2Parameters() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param1 param2");
+    assertEquals("Unparsed string does not match", "",
+        actionRequest.getParameter(3));
+  }
+
+  public void testParemeter3With3Parameters() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param1 param2 " +
+        "param3");
+    assertEquals("Unparsed string does not match", "param3",
+        actionRequest.getParameter(3));
+  }
+
+  public void testParemeter3With4Parameters() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param1 param2 " +
+        "param3 param4");
+    assertEquals("Unparsed string does not match", "param3",
+        actionRequest.getParameter(3));
+  }
+
+  public void testParametersParameterless() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action");
+
+    String[] expected = new String[0];
+    assertEquals("Parameters do not match", Arrays.asList(expected),
+        Arrays.asList(actionRequest.getParameters()));
+  }
+
+  public void testParametersNull() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest(null);
+
+    String[] expected = new String[0];
+    assertEquals("Parameters do not match", Arrays.asList(expected),
+        Arrays.asList(actionRequest.getParameters()));
+  }
+
+  public void testParametersSingleParameter() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param");
+
+    String[] expected = new String[] { "param" };
+    assertEquals("Parameters do not match", Arrays.asList(expected),
+        Arrays.asList(actionRequest.getParameters()));
+  }
+
+  public void testParameters3Parameter() throws IOException {
+    replayMocks();
+
+    ActionRequest actionRequest = createActionRequest("action param1 param2 " +
+        "param3");
+
+    String[] expected = new String[] { "param1", "param2", "param3" };
+    assertEquals("Parameters do not match", Arrays.asList(expected),
+        Arrays.asList(actionRequest.getParameters()));
+  }
+
   private ActionRequest createActionRequest(String specification) {
     ActionRequest.Factory factory = injector.getInstance(
         ActionRequest.Factory.class);
diff --git a/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/action/AddVelocityCommentTest.java b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/action/AddVelocityCommentTest.java
new file mode 100644
index 0000000..5d6b71c
--- /dev/null
+++ b/hooks-its/src/test/java/com/googlesource/gerrit/plugins/hooks/workflow/action/AddVelocityCommentTest.java
@@ -0,0 +1,353 @@
+// 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.hooks.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 java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Writer;
+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;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.FactoryModule;
+import com.google.gerrit.server.config.SitePath;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.hooks.its.ItsFacade;
+import com.googlesource.gerrit.plugins.hooks.testutil.LoggingMockingTestCase;
+import com.googlesource.gerrit.plugins.hooks.workflow.ActionRequest;
+import com.googlesource.gerrit.plugins.hooks.workflow.Property;
+
+public class AddVelocityCommentTest extends LoggingMockingTestCase {
+  private Injector injector;
+
+  private File 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<Property>());
+
+    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<Property>());
+  }
+
+  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 = new Capture<VelocityContext>();
+    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 = new Capture<VelocityContext>();
+    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 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<Property>());
+
+    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<Property>());
+  }
+
+  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 = new Capture<VelocityContext>();
+    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, "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");
+
+    FileWriter unbufferedWriter = new FileWriter(templateFile);
+    BufferedWriter writer = new BufferedWriter(unbufferedWriter);
+    writer.write(template);
+    writer.close();
+    unbufferedWriter.close();
+  }
+
+  public void setUp() throws Exception {
+    super.setUp();
+    cleanupSitePath = false;
+    injector = Guice.createInjector(new TestModule());
+  }
+
+  public void tearDown() throws Exception {
+    if (cleanupSitePath) {
+      if (sitePath.exists()) {
+        FileUtils.delete(sitePath, FileUtils.RECURSIVE);
+      }
+    }
+    super.tearDown();
+  }
+
+  private File randomTargetFile() {
+    final File t = new File("target");
+    return new File(t, "random-name-" + UUID.randomUUID().toString());
+  }
+
+  private class TestModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      sitePath = randomTargetFile();
+      assertFalse("sitePath already (" + sitePath + ") already exists",
+          sitePath.exists());
+      cleanupSitePath = true;
+
+      bind(File.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;
+    }
+  }
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index a1ee2c9..bdd790e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -30,6 +30,7 @@
     <easymockVersion>3.0</easymockVersion>
     <powermockVersion>1.5</powermockVersion>
     <slf4jVersion>1.6.2</slf4jVersion>
+    <velocityVersion>1.6.4</velocityVersion>
   </properties>
 
   <modules>