Merge "Add add-project action"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacade.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacade.java
index cf5a71a..7d4a3c8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacade.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacade.java
@@ -16,19 +16,24 @@
 
 import java.io.IOException;
 import java.net.URL;
+import java.util.Set;
 
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gson.JsonElement;
 import com.google.inject.Inject;
 
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.Conduit;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.ConduitErrorException;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.ConduitException;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestInfo;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ProjectInfo;
 
 public class PhabricatorItsFacade implements ItsFacade {
   private static final Logger log = LoggerFactory.getLogger(PhabricatorItsFacade.class);
@@ -94,11 +99,44 @@
   }
 
   @Override
-  public void performAction(final String bugId, final String actionString) {
-    // No custom actions at this point.
-    //
-    // Note that you can use hashtag names in comments to associate a task
-    // with a new project.
+  public void performAction(final String taskIdString, final String actionString)
+      throws IOException {
+    int taskId = Integer.parseInt(taskIdString);
+    String chopped[] = actionString.split(" ");
+    if (chopped.length >= 1) {
+      String action = chopped[0];
+      switch (action) {
+        case "add-project":
+          if (chopped.length == 2) {
+            try {
+              String projectName = chopped[1];
+
+              ProjectInfo projectInfo = conduit.projectQuery(projectName);
+              String projectPhid = projectInfo.getPhid();
+
+              Set<String> projectPhids = Sets.newHashSet(projectPhid);
+
+              ManiphestInfo taskInfo = conduit.maniphestInfo(taskId);
+              for (JsonElement jsonElement :
+                taskInfo.getProjectPHIDs().getAsJsonArray()) {
+                projectPhids.add(jsonElement.getAsString());
+              }
+
+              conduit.maniphestUpdate(taskId, projectPhids);
+            } catch (ConduitException e) {
+              throw new IOException("Error on conduit", e);
+            }
+          } else {
+            throw new IOException("Action ' + action + ' expects exactly "
+              + "1 parameter but " + (chopped.length - 1) + " given");
+          }
+          break;
+        default:
+          throw new IOException("Unknown action ' + action + '");
+      }
+    } else {
+      throw new IOException("Could not parse action ' + actionString + '");
+    }
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/Conduit.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/Conduit.java
index fe42b5b..27b838f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/Conduit.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/Conduit.java
@@ -22,6 +22,8 @@
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ConduitPing;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestInfo;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestUpdate;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ProjectInfo;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.QueryResult;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -29,8 +31,10 @@
 import java.io.UnsupportedEncodingException;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Map.Entry;
 
 import javax.xml.bind.DatatypeConverter;
 
@@ -173,13 +177,57 @@
    * Runs the API's 'maniphest.update' method
    */
   public ManiphestUpdate maniphestUpdate(int taskId, String comment) throws ConduitException {
+    return maniphestUpdate(taskId, comment, null);
+  }
+
+  /**
+   * Runs the API's 'maniphest.update' method
+   */
+  public ManiphestUpdate maniphestUpdate(int taskId, Iterable<String> projects) throws ConduitException {
+    return maniphestUpdate(taskId, null, projects);
+  }
+
+  /**
+   * Runs the API's 'maniphest.update' method
+   */
+  public ManiphestUpdate maniphestUpdate(int taskId, String comment, Iterable<String> projects) throws ConduitException {
     Map<String, Object> params = new HashMap<String, Object>();
     fillInSession(params);
     params.put("id", taskId);
-    params.put("comments", comment);
+    if (comment != null) {
+      params.put("comments", comment);
+    }
+    if (projects != null) {
+      params.put("projectPHIDs", projects);
+    }
 
     JsonElement callResult = conduitConnection.call("maniphest.update", params);
     ManiphestUpdate result = gson.fromJson(callResult, ManiphestUpdate.class);
     return result;
   }
+
+  /**
+   * Runs the API's 'projectQuery' method to match exactly one project name
+   */
+  public ProjectInfo projectQuery(String name) throws ConduitException {
+    Map<String, Object> params = new HashMap<String, Object>();
+    fillInSession(params);
+    params.put("names", Arrays.asList(name));
+
+    JsonElement callResult = conduitConnection.call("project.query", params);
+    QueryResult queryResult = gson.fromJson(callResult, QueryResult.class);
+    JsonObject queryResultData = queryResult.getData().getAsJsonObject();
+
+    ProjectInfo result = null;
+    for (Entry<String, JsonElement> queryResultEntry:
+      queryResultData.entrySet()) {
+      JsonElement queryResultEntryValue = queryResultEntry.getValue();
+      ProjectInfo queryResultProjectInfo =
+          gson.fromJson(queryResultEntryValue, ProjectInfo.class);
+      if (queryResultProjectInfo.getName().equals(name)) {
+        result = queryResultProjectInfo;
+      }
+    }
+    return result;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectInfo.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectInfo.java
new file mode 100644
index 0000000..a3414c9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectInfo.java
@@ -0,0 +1,79 @@
+//Copyright (C) 2015 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.phabricator.conduit.results;
+
+import com.google.gson.JsonElement;
+
+/**
+ * Models the result for API methods returning Project information
+ * <p/>
+ * JSON looks like:
+ * <pre>
+ * {
+ *   "id":"23",
+ *   "phid":"PHID-PROJ-lxmsio4ggx63mhakxhnn",
+ *   "name":"QChris-Test-Project",
+ *   "profileImagePHID":null,
+ *   "icon":"briefcase",
+ *   "color":"blue",
+ *   "members":["PHID-USER-kem5g5ua7s75ffvlzwgk","PHID-USER-h4n62fq2kt2v3a2qjyqh"],
+ *   "slugs":["qchris-test-project"],
+ *   "dateCreated":"1413551900",
+ *   "dateModified":"1424557030"
+ * }
+ * </pre>
+ */
+public class ProjectInfo {
+  private int id;
+  private String phid;
+  private String name;
+  private String profileImagePHID;
+  private String icon;
+  private String color;
+  private JsonElement members;
+  private JsonElement slugs;
+  private String dateCreated;
+  private String dateModified;
+
+  public int getId() {
+    return id;
+  }
+  public String getPhid() {
+    return phid;
+  }
+  public String getName() {
+    return name;
+  }
+  public String getProfileImagePHID() {
+    return profileImagePHID;
+  }
+  public String getIcon() {
+    return icon;
+  }
+  public String getColor() {
+    return color;
+  }
+  public JsonElement getMembers() {
+    return members;
+  }
+  public JsonElement getSlugs() {
+    return slugs;
+  }
+  public String getDateCreated() {
+    return dateCreated;
+  }
+  public String getDateModified() {
+    return dateModified;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/QueryResult.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/QueryResult.java
new file mode 100644
index 0000000..bd0467d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/QueryResult.java
@@ -0,0 +1,50 @@
+//Copyright (C) 2015 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.phabricator.conduit.results;
+
+import com.google.gson.JsonElement;
+
+/**
+ * Models the result for API methods returning a (possible paged) QueryResult
+ * <p/>
+ * JSON looks like:
+ * <pre>
+ * {
+ *   "data": { ... },
+ *   "slugMap": [],
+ *   "cursor": {
+ *     "limit": 100,
+ *     "after": null,
+ *     "before": null
+ *   }
+ * }
+   * </pre>
+   */
+public class QueryResult {
+  private JsonElement data;
+  private JsonElement slugMap;
+  private JsonElement cursor;
+
+  public JsonElement getData() {
+    return data;
+  }
+
+  public JsonElement getSlugMap() {
+    return slugMap;
+  }
+
+  public JsonElement getCursor() {
+    return cursor;
+  }
+}
diff --git a/src/main/resources/Documentation/config-rulebase-plugin-actions.md b/src/main/resources/Documentation/config-rulebase-plugin-actions.md
new file mode 100644
index 0000000..487be43
--- /dev/null
+++ b/src/main/resources/Documentation/config-rulebase-plugin-actions.md
@@ -0,0 +1,28 @@
+@PLUGIN@-specific actions
+=========================
+
+In addition to the [basic actions][basic-actions], @PLUGIN@ also
+provides:
+
+[`add-project`][action-add-project]
+: adds a project to the task
+
+[basic-actions]: config-rulebase-common.html#actions
+
+[action-add-project]: #action-add-project
+### <a name="action-add-project">Action: add-project</a>
+
+The `add-project` action adds a project to the task. The first
+parameter is the project name to add. So for example
+
+```
+  action = add-project MyCoolProject
+```
+
+adds the project `MyCoolProject` to the task.
+
+
+
+[Back to @PLUGIN@ documentation index][index]
+
+[index]: index.html
\ No newline at end of file
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitTest.java
index eb99802..f2e80e6 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitTest.java
@@ -24,8 +24,13 @@
 import org.powermock.core.classloader.annotations.PrepareForTest;
 import org.powermock.modules.junit4.PowerMockRunner;
 
+import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonPrimitive;
 
@@ -34,6 +39,7 @@
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ConduitPing;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestInfo;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestUpdate;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ProjectInfo;
 
 @RunWith(PowerMockRunner.class)
 @PrepareForTest(Conduit.class)
@@ -242,7 +248,7 @@
     assertLogMessageContains("Trying to start new session");
   }
 
-  public void testManiphestUpdatePass() throws Exception {
+  public void testManiphestUpdatePassComment() throws Exception {
     mockConnection();
 
     resetToStrict(connection);
@@ -282,6 +288,94 @@
     assertLogMessageContains("Trying to start new session");
   }
 
+  public void testManiphestUpdatePassProjects() throws Exception {
+    mockConnection();
+
+    resetToStrict(connection);
+
+    JsonObject retConnect = new JsonObject();
+    retConnect.add("sessionKey", new JsonPrimitive("KeyFoo"));
+
+    Capture<Map<String, Object>> paramsCaptureConnect = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("conduit.connect"), capture(paramsCaptureConnect)))
+      .andReturn(retConnect)
+      .once();
+
+    JsonObject retRelevant = new JsonObject();
+    retRelevant.add("id", new JsonPrimitive(42));
+
+    Capture<Map<String, Object>> paramsCaptureRelevant = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("maniphest.update"), capture(paramsCaptureRelevant)))
+    .andReturn(retRelevant)
+    .once();
+
+    replayMocks();
+
+    Conduit conduit = new Conduit(URL, USERNAME, CERTIFICATE);
+
+    ManiphestUpdate maniphestUpdate = conduit.maniphestUpdate(42,
+        Arrays.asList("foo", "bar"));
+
+    Map<String, Object> paramsConnect = paramsCaptureConnect.getValue();
+    assertEquals("Usernames do not match", USERNAME, paramsConnect.get("user"));
+
+    Map<String, Object> paramsRelevant = paramsCaptureRelevant.getValue();
+    assertEquals("Task id is not set", 42, paramsRelevant.get("id"));
+    assertEquals("Task projects are not set", Arrays.asList("foo", "bar"),
+        paramsRelevant.get("projectPHIDs"));
+
+    assertEquals("ManiphestUpdate's id does not match", 42, maniphestUpdate.getId());
+
+    assertLogMessageContains("Trying to start new session");
+  }
+
+  public void testManiphestUpdatePassCommentAndProjects() throws Exception {
+    mockConnection();
+
+    resetToStrict(connection);
+
+    JsonObject retConnect = new JsonObject();
+    retConnect.add("sessionKey", new JsonPrimitive("KeyFoo"));
+
+    Capture<Map<String, Object>> paramsCaptureConnect = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("conduit.connect"), capture(paramsCaptureConnect)))
+      .andReturn(retConnect)
+      .once();
+
+    JsonObject retRelevant = new JsonObject();
+    retRelevant.add("id", new JsonPrimitive(42));
+
+    Capture<Map<String, Object>> paramsCaptureRelevant = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("maniphest.update"), capture(paramsCaptureRelevant)))
+    .andReturn(retRelevant)
+    .once();
+
+    replayMocks();
+
+    Conduit conduit = new Conduit(URL, USERNAME, CERTIFICATE);
+
+    ManiphestUpdate maniphestUpdate = conduit.maniphestUpdate(42, "baz",
+        Arrays.asList("foo", "bar"));
+
+    Map<String, Object> paramsConnect = paramsCaptureConnect.getValue();
+    assertEquals("Usernames do not match", USERNAME, paramsConnect.get("user"));
+
+    Map<String, Object> paramsRelevant = paramsCaptureRelevant.getValue();
+    assertEquals("Task id is not set", 42, paramsRelevant.get("id"));
+    assertEquals("Task comment is not set", "baz", paramsRelevant.get("comments"));
+    assertEquals("Task projects are not set", Arrays.asList("foo", "bar"),
+        paramsRelevant.get("projectPHIDs"));
+
+    assertEquals("ManiphestUpdate's id does not match", 42, maniphestUpdate.getId());
+
+    assertLogMessageContains("Trying to start new session");
+  }
+
+
   public void testManiphestUpdateFailConnect() throws Exception {
     mockConnection();
 
@@ -393,6 +487,114 @@
     assertEquals("ManiphestInfo's id does not match", 42, maniphestInfo.getId());
   }
 
+  public void testProjectQueryPass() throws Exception {
+    mockConnection();
+
+    resetToStrict(connection);
+
+    JsonObject retConnect = new JsonObject();
+    retConnect.add("sessionKey", new JsonPrimitive("KeyFoo"));
+
+    Capture<Map<String, Object>> paramsCaptureConnect = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("conduit.connect"), capture(paramsCaptureConnect)))
+      .andReturn(retConnect)
+      .once();
+
+    JsonObject projectInfoJson = new JsonObject();
+    projectInfoJson.addProperty("name", "foo");
+    projectInfoJson.addProperty("phid", "PHID-PROJ-bar");
+
+    JsonObject queryDataJson = new JsonObject();
+    queryDataJson.add("PHID-PROJ-bar", projectInfoJson);
+
+    JsonObject retRelevant = new JsonObject();
+    retRelevant.add("data", queryDataJson);
+
+    Capture<Map<String, Object>> paramsCaptureRelevant = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("project.query"), capture(paramsCaptureRelevant)))
+    .andReturn(retRelevant)
+    .once();
+
+    replayMocks();
+
+    Conduit conduit = new Conduit(URL, USERNAME, CERTIFICATE);
+
+    ProjectInfo projectInfo = conduit.projectQuery("foo");
+
+    Map<String, Object> paramsConnect = paramsCaptureConnect.getValue();
+    assertEquals("Usernames do not match", USERNAME, paramsConnect.get("user"));
+
+    Map<String, Object> paramsRelevant = paramsCaptureRelevant.getValue();
+    List<String> expectedNames = Arrays.asList("foo");
+    assertEquals("Project name does not match", expectedNames,
+        paramsRelevant.get("names"));
+
+    assertEquals("ProjectInfo's name does not match", "foo", projectInfo.getName());
+
+    assertLogMessageContains("Trying to start new session");
+  }
+
+  public void testProjectQueryPassMultipleResults() throws Exception {
+    mockConnection();
+
+    resetToStrict(connection);
+
+    JsonObject retConnect = new JsonObject();
+    retConnect.add("sessionKey", new JsonPrimitive("KeyFoo"));
+
+    Capture<Map<String, Object>> paramsCaptureConnect = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("conduit.connect"), capture(paramsCaptureConnect)))
+      .andReturn(retConnect)
+      .once();
+
+    JsonObject projectInfoJson1 = new JsonObject();
+    projectInfoJson1.addProperty("name", "foo1");
+    projectInfoJson1.addProperty("phid", "PHID-PROJ-bar1");
+
+    JsonObject projectInfoJson2 = new JsonObject();
+    projectInfoJson2.addProperty("name", "foo2");
+    projectInfoJson2.addProperty("phid", "PHID-PROJ-bar2");
+
+    JsonObject projectInfoJson3 = new JsonObject();
+    projectInfoJson3.addProperty("name", "foo3");
+    projectInfoJson3.addProperty("phid", "PHID-PROJ-bar3");
+
+    JsonObject queryDataJson = new JsonObject();
+    queryDataJson.add("PHID-PROJ-bar1", projectInfoJson1);
+    queryDataJson.add("PHID-PROJ-bar2", projectInfoJson2);
+    queryDataJson.add("PHID-PROJ-bar3", projectInfoJson3);
+
+    JsonObject retRelevant = new JsonObject();
+    retRelevant.add("data", queryDataJson);
+
+    Capture<Map<String, Object>> paramsCaptureRelevant = new Capture<Map<String, Object>>();
+
+    expect(connection.call(eq("project.query"), capture(paramsCaptureRelevant)))
+    .andReturn(retRelevant)
+    .once();
+
+    replayMocks();
+
+    Conduit conduit = new Conduit(URL, USERNAME, CERTIFICATE);
+
+    ProjectInfo projectInfo = conduit.projectQuery("foo2");
+
+    Map<String, Object> paramsConnect = paramsCaptureConnect.getValue();
+    assertEquals("Usernames do not match", USERNAME, paramsConnect.get("user"));
+
+    Map<String, Object> paramsRelevant = paramsCaptureRelevant.getValue();
+    List<String> expectedNames = Arrays.asList("foo2");
+    assertEquals("Project name does not match", expectedNames,
+        paramsRelevant.get("names"));
+
+    assertEquals("ProjectInfo's name does not match", "foo2", projectInfo.getName());
+
+    assertLogMessageContains("Trying to start new session");
+  }
+
   private void mockConnection() throws Exception {
     connection = createMock(ConduitConnection.class);;
     expectNew(ConduitConnection.class, URL)