Merge branch 'stable-3.0'

* stable-3.0:
  Add custom stamp to add its-base revision to version number

Change-Id: Id748494753b7d4575b863d5edf2a2a176aae9bdf
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 f2bd4ff..c7e3fc6 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
@@ -14,54 +14,45 @@
 
 package com.googlesource.gerrit.plugins.its.phabricator;
 
-import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gson.Gson;
-import com.google.gson.JsonArray;
-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.ManiphestSearch;
-import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestResults;
-import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ProjectSearch;
 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;
 
 public class PhabricatorItsFacade implements ItsFacade {
-  private static final Logger log = LoggerFactory.getLogger(PhabricatorItsFacade.class);
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final String GERRIT_CONFIG_URL = "url";
   private static final String GERRIT_CONFIG_TOKEN = "token";
 
   private final Conduit conduit;
-  private final Gson gson;
 
   @Inject
-  public PhabricatorItsFacade(@PluginName String pluginName, @GerritServerConfig Config cfg) {
+  public PhabricatorItsFacade(
+      @PluginName String pluginName,
+      @GerritServerConfig Config cfg,
+      Conduit.Factory conduitFactory) {
     String url = cfg.getString(pluginName, null, GERRIT_CONFIG_URL);
     String token = cfg.getString(pluginName, null, GERRIT_CONFIG_TOKEN);
 
-    this.conduit = new Conduit(url, token);
-    this.gson = new Gson();
+    this.conduit = conduitFactory.create(url, token);
   }
 
   @Override
   public void addComment(final String bugId, final String comment) throws IOException {
     int task_id = Integer.parseInt(bugId);
     try {
-      conduit.maniphestEdit(task_id, comment);
+      conduit.maniphestEdit(task_id, comment, null, null);
     } catch (ConduitException e) {
-      throw new IOException("Could not update message for task " + task_id, e);
+      throw new IOException("Could not add comment for task " + task_id, e);
     }
-    log.debug("Added comment " + comment + " to bug " + task_id);
+    logger.atFine().log("Added comment %s to bug %s", comment, task_id);
   }
 
   @Override
@@ -76,18 +67,7 @@
     Boolean ret = false;
     int task_id = Integer.parseInt(bugId);
     try {
-      try {
-        conduit.maniphestSearch(task_id);
-        ret = true;
-      } catch (ConduitErrorException e) {
-        // An ERR_BAD_TASK just means that the task does not exist.
-        // So the default value of ret would be ok
-        if (!("ERR_BAD_TASK".equals(e.getErrorCode()))) {
-          // So we had an exception that is /not/ ERR_BAD_TASK.
-          // We have to relay that to the caller.
-          throw e;
-        }
-      }
+      ret = (conduit.maniphestSearch(task_id) != null);
     } catch (ConduitException e) {
       throw new IOException("Could not check existence of task " + task_id, e);
     }
@@ -101,19 +81,21 @@
     String chopped[] = actionString.split(" ");
     if (chopped.length >= 1) {
       String action = chopped[0];
-      switch (action) {
-        case "add-project":
-          assertParameters(action, chopped, 1);
-
-          maniphestEdit(chopped[1], taskId, Conduit.ACTION_PROJECT_ADD);
-          break;
-        case "remove-project":
-          assertParameters(action, chopped, 1);
-
-          maniphestEdit(chopped[1], taskId, Conduit.ACTION_PROJECT_REMOVE);
-          break;
-        default:
-          throw new IOException("Unknown action " + action);
+      try {
+        switch (action) {
+          case "add-project":
+            assertParameters(action, chopped, 1);
+            conduit.maniphestEdit(taskId, null, chopped[1], null);
+            break;
+          case "remove-project":
+            assertParameters(action, chopped, 1);
+            conduit.maniphestEdit(taskId, null, null, chopped[1]);
+            break;
+          default:
+            throw new IOException("Unknown action " + action);
+        }
+      } catch (ConduitException e) {
+        throw new IOException("Could not perform action " + action, e);
       }
     } else {
       throw new IOException("Could not parse action " + actionString);
@@ -129,29 +111,6 @@
     }
   }
 
-  private void maniphestEdit(String projectName, int taskId, String actions) throws IOException {
-    try {
-      ProjectSearch projectSearch = conduit.projectSearch(projectName);
-      String projectPhid = projectSearch.getPhid();
-
-      Set<String> projectPhids = Sets.newHashSet(projectPhid);
-
-      ManiphestResults taskSearch = conduit.maniphestSearch(taskId);
-      JsonArray maniphestResultEntryValue = taskSearch.getData().getAsJsonArray();
-
-      for (JsonElement jsonElement : maniphestResultEntryValue) {
-        ManiphestSearch maniphestResultManiphestSearch = gson.fromJson(jsonElement, ManiphestSearch.class);
-        for (JsonElement jsonElement2 : maniphestResultManiphestSearch.getAttachments().getProjects().getProjectPHIDs().getAsJsonArray()) {
-          projectPhids.add(jsonElement2.getAsString());
-        }
-      }
-
-      conduit.maniphestEdit(taskId, projectPhids, actions);
-    } catch (ConduitException e) {
-      throw new IOException("Error on conduit", e);
-    }
-  }
-
   @Override
   public String healthCheck(final Check check) throws IOException {
     // This method is not used, so there is no need to implement it.
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorModule.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorModule.java
index 847e7bb..402316e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorModule.java
@@ -14,22 +14,24 @@
 
 package com.googlesource.gerrit.plugins.its.phabricator;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
-import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.google.inject.Scopes;
 import com.googlesource.gerrit.plugins.its.base.ItsHookModule;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacadeFactory;
 import com.googlesource.gerrit.plugins.its.base.its.SingleItsServer;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.Conduit;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.ConduitConnection;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
-public class PhabricatorModule extends AbstractModule {
+public class PhabricatorModule extends FactoryModule {
 
-  private static final Logger log = LoggerFactory.getLogger(PhabricatorModule.class);
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final String pluginName;
   private final Config gerritConfig;
@@ -48,8 +50,10 @@
   @Override
   protected void configure() {
     if (gerritConfig.getString(pluginName, null, "url") != null) {
-      log.info("Phabricator is configured as ITS");
-      bind(ItsFacade.class).toInstance(new PhabricatorItsFacade(pluginName, gerritConfig));
+      logger.atInfo().log("Phabricator is configured as ITS");
+      factory(ConduitConnection.Factory.class);
+      factory(Conduit.Factory.class);
+      bind(ItsFacade.class).to(PhabricatorItsFacade.class).in(Scopes.SINGLETON);
       bind(ItsFacadeFactory.class).to(SingleItsServer.class);
 
       install(new ItsHookModule(pluginName, pluginCfgFactory));
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 0ff669a..a8e9d69 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
@@ -14,24 +14,22 @@
 
 package com.googlesource.gerrit.plugins.its.phabricator.conduit;
 
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gson.Gson;
-import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ConduitPing;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestEdit;
-import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestResults;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestSearch;
-import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ProjectResults;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ProjectSearch;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * Bindings for Phabricator's Conduit API
@@ -39,6 +37,9 @@
  * <p>This class is not thread-safe.
  */
 public class Conduit {
+  public interface Factory {
+    Conduit create(@Assisted("baseUrl") String baseUrl, @Assisted("token") String token);
+  }
 
   public static final String ACTION_COMMENT = "comment";
 
@@ -46,29 +47,25 @@
 
   public static final String ACTION_PROJECT_REMOVE = "projects.remove";
 
-  private static final Logger log = LoggerFactory.getLogger(Conduit.class);
-
   public static final int CONDUIT_VERSION = 7;
 
+  private final SearchUtils searchUtils;
   private final ConduitConnection conduitConnection;
   private final Gson gson;
+  private final String token;
 
-  private String token;
-
-  public Conduit(final String baseUrl) {
-    this(baseUrl, null);
-  }
-
-  public Conduit(final String baseUrl, final String token) {
-    this.conduitConnection = new ConduitConnection(baseUrl);
+  @Inject
+  public Conduit(
+      ConduitConnection.Factory conduitConnectionFactory,
+      SearchUtils searchUtils,
+      @Assisted("baseUrl") String baseUrl,
+      @Assisted("token") String token) {
+    this.searchUtils = searchUtils;
+    this.conduitConnection = conduitConnectionFactory.create(baseUrl);
     this.token = token;
     this.gson = new Gson();
   }
 
-  public void setToekn(String token) {
-    this.token = token;
-  }
-
   /** Runs the API's 'conduit.ping' method */
   public ConduitPing conduitPing() throws ConduitException {
     Map<String, Object> params = new HashMap<>();
@@ -79,96 +76,63 @@
     return result;
   }
 
-  /**
-   * Runs the API's 'maniphest.search' method
-   */
-  public ManiphestResults maniphestSearch(int taskId) throws ConduitException {
+  /** Runs the API's 'maniphest.search' method */
+  public ManiphestSearch maniphestSearch(int taskId) throws ConduitException {
     HashMap<String, Object> params = new HashMap<>();
-    HashMap<String, Object> params2 = new HashMap<>();
-    HashMap<String, Object> params3 = new HashMap<>();
-
-    List<Object> list = new ArrayList<>();
-    list.add(taskId);
-
-    params2.put("ids", list);
-
-    params.put("constraints", params2);
-
-    params3.put("projects", true);
-    params.put("attachments", params3);
+    params.put("constraints", ImmutableMap.of("ids", ImmutableList.of(taskId)));
 
     JsonElement callResult = conduitConnection.call("maniphest.search", params, token);
-    ManiphestResults result = gson.fromJson(callResult, ManiphestResults.class);
-    return result;
-  }
-
-  /** Runs the API's 'maniphest.edit' method */
-  public ManiphestEdit maniphestEdit(int taskId, String comment) throws ConduitException {
-    return maniphestEdit(taskId, comment, null, ACTION_COMMENT);
-  }
-
-  /** Runs the API's 'maniphest.edit' method */
-  public ManiphestEdit maniphestEdit(int taskId, Iterable<String> projects, String action)
-      throws ConduitException {
-    return maniphestEdit(taskId, null, projects, action);
+    return searchUtils.stream(callResult, ManiphestSearch.class).findFirst().orElse(null);
   }
 
   /** Runs the API's 'maniphest.edit' method */
   public ManiphestEdit maniphestEdit(
-      int taskId, String comment, Iterable<String> projects, String action)
+      int taskId, String comment, String projectNameToAdd, String projectNameToRemove)
       throws ConduitException {
-    HashMap<String, Object> params = new HashMap<>();
-    List<Object> list = new ArrayList<>();
-    HashMap<String, Object> params2 = new HashMap<>();
-    params2.put("type", action);
-    if (action.equals(ACTION_COMMENT)) {
-      if (comment == null) {
-        throw new IllegalArgumentException(
-            "The value of comment (null) is invalid for the action" + action);
-      }
-      params2.put("value", comment);
+    ManiphestEdit result = null;
+    List<Object> transactions = new ArrayList<>();
+
+    if (!Strings.isNullOrEmpty(comment)) {
+      HashMap<String, Object> transaction = new HashMap<>();
+      transaction.put("type", ACTION_COMMENT);
+      transaction.put("value", comment);
+
+      transactions.add(transaction);
     }
 
-    if (action.equals(ACTION_PROJECT_ADD) || action.equals(ACTION_PROJECT_REMOVE)) {
-      if ((action.equals(ACTION_PROJECT_ADD) || action.equals(ACTION_PROJECT_REMOVE))
-          && projects == null) {
-        throw new IllegalArgumentException(
-            "The value of projects (null) is invalid for the action " + action);
-      }
-      params2.put("value", projects);
+    if (!Strings.isNullOrEmpty(projectNameToAdd)) {
+      HashMap<String, Object> transaction = new HashMap<>();
+      transaction.put("type", ACTION_PROJECT_ADD);
+      transaction.put("value", ImmutableList.of(projectSearch(projectNameToAdd).getPhid()));
+
+      transactions.add(transaction);
     }
 
-    if (!params2.isEmpty()) {
-      list.add(params2);
-      params.put("transactions", list);
-    }
-    params.put("objectIdentifier", taskId);
+    if (!Strings.isNullOrEmpty(projectNameToRemove)) {
+      HashMap<String, Object> transaction = new HashMap<>();
+      transaction.put("type", ACTION_PROJECT_REMOVE);
+      transaction.put("value", ImmutableList.of(projectSearch(projectNameToRemove).getPhid()));
 
-    JsonElement callResult = conduitConnection.call("maniphest.edit", params, token);
-    ManiphestEdit result = gson.fromJson(callResult, ManiphestEdit.class);
+      transactions.add(transaction);
+    }
+
+    if (!transactions.isEmpty()) {
+      HashMap<String, Object> params = new HashMap<>();
+      params.put("objectIdentifier", taskId);
+      params.put("transactions", transactions);
+      JsonElement callResult = conduitConnection.call("maniphest.edit", params, token);
+      result = gson.fromJson(callResult, ManiphestEdit.class);
+    }
+
     return result;
   }
 
-  /** Runs the API's 'projectSearch' method to match exactly one project name */
+  /** Runs the API's 'project.search' method to match exactly one project name */
   public ProjectSearch projectSearch(String name) throws ConduitException {
     HashMap<String, Object> params = new HashMap<>();
-    HashMap<String, Object> params2 = new HashMap<>();
-
-    params2.put("query", name);
-
-    params.put("constraints", params2);
+    params.put("constraints", ImmutableMap.of("query", name));
 
     JsonElement callResult = conduitConnection.call("project.search", params, token);
-    ProjectResults projectResult = gson.fromJson(callResult, ProjectResults.class);
-    JsonArray projectResultData = projectResult.getData().getAsJsonArray();
-
-    ProjectSearch result = null;
-    for (JsonElement jsonElement : projectResultData) {
-      ProjectSearch projectResultSearch = gson.fromJson(jsonElement, ProjectSearch.class);
-      if (projectResultSearch.getFields().getName().equals(name)) {
-        result = projectResultSearch;
-      }
-    }
-    return result;
+    return searchUtils.stream(callResult, ProjectSearch.class).findFirst().orElse(null);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitConnection.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitConnection.java
index 9d93080..8eb695b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitConnection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitConnection.java
@@ -14,8 +14,11 @@
 
 package com.googlesource.gerrit.plugins.its.phabricator.conduit;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.gson.Gson;
 import com.google.gson.JsonElement;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.CallCapsule;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -28,19 +31,22 @@
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.impl.client.HttpClients;
 import org.apache.http.util.EntityUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Abstracts the connection to Conduit API */
-class ConduitConnection {
-  private static final Logger log = LoggerFactory.getLogger(Conduit.class);
+public class ConduitConnection {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    ConduitConnection create(String baseUrl);
+  }
 
   private final String apiUrlBase;
   private final Gson gson;
 
   private CloseableHttpClient client;
 
-  ConduitConnection(final String baseUrl) {
+  @Inject
+  ConduitConnection(@Assisted String baseUrl) {
     apiUrlBase = baseUrl.replaceAll("/+$", "") + "/api/";
     gson = new Gson();
     client = null;
@@ -55,7 +61,7 @@
    */
   private CloseableHttpClient getClient() {
     if (client == null) {
-      log.trace("Creating new client connection");
+      logger.atFinest().log("Creating new client connection");
       client = HttpClients.createDefault();
     }
     return client;
@@ -94,17 +100,11 @@
 
     String json = gson.toJson(params);
 
-    log.trace("Calling phabricator method " + method + " with the parameters " + json);
+    logger.atFinest().log("Calling phabricator method %s with the parameters %s", method, json);
     httppost.setEntity(new StringEntity("params=" + json, StandardCharsets.UTF_8));
 
-    CloseableHttpResponse response;
-    try {
-      response = getClient().execute(httppost);
-    } catch (IOException e) {
-      throw new ConduitException("Could not execute Phabricator API call", e);
-    }
-    try {
-      log.trace("Phabricator HTTP response status: " + response.getStatusLine());
+    try (CloseableHttpResponse response = getClient().execute(httppost)) {
+      logger.atFinest().log("Phabricator HTTP response status: %s", response.getStatusLine());
       HttpEntity entity = response.getEntity();
       String entityString;
       try {
@@ -113,22 +113,18 @@
         throw new ConduitException("Could not read the API response", e);
       }
 
-      log.trace("Phabricator response " + entityString);
+      logger.atFinest().log("Phabricator response: %s", entityString);
       CallCapsule callCapsule = gson.fromJson(entityString, CallCapsule.class);
-      log.trace("callCapsule.result: " + callCapsule.getResult());
-      log.trace("callCapsule.error_code: " + callCapsule.getErrorCode());
-      log.trace("callCapsule.error_info: " + callCapsule.getErrorInfo());
+      logger.atFinest().log("callCapsule.result: %s", callCapsule.getResult());
+      logger.atFinest().log("callCapsule.error_code: %s", callCapsule.getErrorCode());
+      logger.atFinest().log("callCapsule.error_info: %s", callCapsule.getErrorInfo());
       if (callCapsule.getErrorCode() != null || callCapsule.getErrorInfo() != null) {
         throw new ConduitErrorException(
             method, callCapsule.getErrorCode(), callCapsule.getErrorInfo());
       }
       return callCapsule.getResult();
-    } finally {
-      try {
-        response.close();
-      } catch (IOException e) {
-        throw new ConduitException("Could not close API response", e);
-      }
+    } catch (IOException e) {
+      throw new ConduitException("Could not execute Phabricator API call", e);
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/SearchUtils.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/SearchUtils.java
new file mode 100644
index 0000000..0cfaf5c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/SearchUtils.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.its.phabricator.conduit;
+
+import com.google.common.collect.Streams;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.GenericSearch;
+import java.util.stream.Stream;
+
+public class SearchUtils {
+  private final Gson gson;
+
+  @Inject
+  public SearchUtils() {
+    gson = new Gson();
+  }
+
+  public <T> Stream<T> stream(JsonElement jsonResult, Class<T> classOfT) {
+    GenericSearch result = gson.fromJson(jsonResult, GenericSearch.class);
+    return Streams.stream(result.getData()).map((json) -> gson.fromJson(json, classOfT));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/GenericEdit.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/GenericEdit.java
new file mode 100644
index 0000000..94105c3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/GenericEdit.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2020 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 java.util.List;
+
+/**
+ * Models the result for API methods
+ *
+ * <p>JSON looks like:
+ *
+ * <pre>
+ * {
+ *   "object":{
+ *     "id":2,
+ *     "phid":"PHID-TASK-wzydcwamkp5rjhg45ocq"
+ *   },
+ *   "transactions":[
+ *     {"phid":"PHID-XACT-TASK-sghfp7saytwmun3"}
+ *   ]
+ * }
+ * </pre>
+ */
+public class GenericEdit {
+  private ResultObject object;
+  private List<Transaction> transactions;
+
+  public ResultObject getObject() {
+    return object;
+  }
+
+  public List<Transaction> getTransactions() {
+    return transactions;
+  }
+
+  public class Transaction extends PhabObject {}
+
+  public class ResultObject extends PhabObjectWithId {}
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestResults.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/GenericSearch.java
similarity index 88%
rename from src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestResults.java
rename to src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/GenericSearch.java
index 8a28795..c911df8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestResults.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/GenericSearch.java
@@ -14,7 +14,7 @@
 
 package com.googlesource.gerrit.plugins.its.phabricator.conduit.results;
 
-import com.google.gson.JsonElement;
+import com.google.gson.JsonArray;
 
 /**
  * Models the result for API methods
@@ -39,10 +39,10 @@
  * }
  * </pre>
  */
-public class ManiphestResults {
-  private JsonElement data;
+public class GenericSearch {
+  private JsonArray data;
 
-  public JsonElement getData() {
+  public JsonArray getData() {
     return data;
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestEdit.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestEdit.java
index e9e3805..dc416dd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestEdit.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestEdit.java
@@ -20,31 +20,14 @@
  *
  * <pre>
  * {
- *   "id":"48",
- *   "phid":"PHID-TASK-pemd324eosnymq3tdkyo",
- *   "authorPHID":"PHID-USER-na3one2sht11aone",
- *   "ownerPHID":null,
- *   "ccPHIDs":[
- *     "PHID-USER-h4n62fq2kt2v3a2qjyqh"
- *   ],
- *   "status":"open",
- *   "statusName":"Open",
- *   "isClosed":false,
- *   "priority": "Needs Triage",
- *   "priorityColor":"violet",
- *   "title":"QChris test task",
- *   "description":"",
- *   "projectPHIDs":[],
- *   "uri":"https://phab-01.wmflabs.org/T47",
- *   "auxiliary":{
- *     "std:maniphest:security_topic":"default",
- *     "isdc:sprint:storypoints":null
+ *   "object":{
+ *     "id":2,
+ *     "phid":"PHID-TASK-wzydcwamkp5rjhg45ocq"
  *   },
- *   "objectName":"T47",
- *   "dateCreated":"1413484594",
- *   "dateModified":1413549869,
- *   "dependsOnTaskPHIDs":[]
+ *   "transactions":[
+ *     {"phid":"PHID-XACT-TASK-sghfp7saytwmun3"}
+ *   ]
  * }
  * </pre>
  */
-public class ManiphestEdit extends Task {}
+public class ManiphestEdit extends GenericEdit {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestSearch.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestSearch.java
index 1f98709..48a81e1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestSearch.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ManiphestSearch.java
@@ -14,8 +14,6 @@
 
 package com.googlesource.gerrit.plugins.its.phabricator.conduit.results;
 
-import com.google.gson.JsonElement;
-
 /**
  * Models the result for a call to maniphest.search
  *
@@ -67,36 +65,4 @@
  * }
  * </pre>
  */
-public class ManiphestSearch {
-  private int id;
-  private JsonElement fields;
-  private Attachments attachments;
-
-  public int getId() {
-    return id;
-  }
-
-  public JsonElement getFields() {
-    return fields;
-  }
-
-  public Attachments getAttachments() {
-    return attachments;
-  }
-
-  public class Attachments {
-    private Projects projects;
-
-    public Projects getProjects() {
-      return projects;
-    }
-  }
-
-  public class Projects {
-    private JsonElement projectPHIDs;
-
-    public JsonElement getProjectPHIDs() {
-      return projectPHIDs;
-    }
-  }
-}
+public class ManiphestSearch extends PhabObjectWithId {}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/PhabObject.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/PhabObject.java
new file mode 100644
index 0000000..90c4819
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/PhabObject.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2020 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;
+
+public class PhabObject {
+  private String phid;
+
+  public PhabObject() {
+    this(null);
+  }
+
+  public PhabObject(String phid) {
+    this.phid = phid;
+  }
+
+  public String getPhid() {
+    return phid;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/PhabObjectWithId.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/PhabObjectWithId.java
new file mode 100644
index 0000000..dd8c1b9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/PhabObjectWithId.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2020 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;
+
+public class PhabObjectWithId extends PhabObject {
+  private int id;
+
+  public PhabObjectWithId() {
+    super();
+  }
+
+  public PhabObjectWithId(String phid, int id) {
+    super(phid);
+    this.id = id;
+  }
+
+  public int getId() {
+    return id;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectResults.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectResults.java
deleted file mode 100644
index fcd9b44..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectResults.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// 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.phabricator.conduit.results;
-
-import com.google.gson.JsonElement;
-
-/**
- * Models the result for API methods
- *
- * <p>JSON looks like:
- *
- * <pre>
- * {
- *   "data": [
- *     { ... }
- *   ],
- *   "maps": {},
- *   "query": {
- *     "queryKey": null
- *   },
- *   "cursor": {
- *     "limit": 100,
- *     "after": null,
- *     "before": null,
- *     "order": null
- *   }
- * }
- * </pre>
- */
-public class ProjectResults {
-  private JsonElement data;
-
-  public JsonElement getData() {
-    return data;
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectSearch.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectSearch.java
index 7410c68..b4c3d53 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectSearch.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/ProjectSearch.java
@@ -14,12 +14,10 @@
 
 package com.googlesource.gerrit.plugins.its.phabricator.conduit.results;
 
-import com.google.gson.JsonElement;
-
 /**
  * Models the result for API methods returning Project searches.
  *
- * <p>JSON looks like:</p>
+ * <p>JSON looks like:
  *
  * <pre>
  * {
@@ -58,33 +56,8 @@
  * }
  * </pre>
  */
-public class ProjectSearch {
-  private int id;
-  private String type;
-  private String phid;
-  private Fields fields;
-
-  public int getId() {
-    return id;
-  }
-
-  public String getType() {
-    return type;
-  }
-
-  public String getPhid() {
-    return phid;
-  }
-
-  public Fields getFields() {
-    return fields;
-  }
-
-  public class Fields {
-    private String name;
-
-    public String getName() {
-      return name;
-    }
+public class ProjectSearch extends PhabObjectWithId {
+  public ProjectSearch(String phid, int id) {
+    super(phid, id);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/Task.java b/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/Task.java
deleted file mode 100644
index 177968d..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/results/Task.java
+++ /dev/null
@@ -1,158 +0,0 @@
-// Copyright (C) 2014 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 Task information
- *
- * <p>JSON looks like:
- *
- * <pre>
- * {
- *   "id":"48",
- *   "phid":"PHID-TASK-pemd324eosnymq3tdkyo",
- *   "authorPHID":"PHID-USER-na3one2sht11aone",
- *   "ownerPHID":null,
- *   "ccPHIDs":[
- *     "PHID-USER-h4n62fq2kt2v3a2qjyqh"
- *   ],
- *   "status":"open",
- *   "statusName":"Open",
- *   "isClosed":false,
- *   "priority": "Needs Triage",
- *   "priorityColor":"violet",
- *   "title":"QChris test task",
- *   "description":"",
- *   "projectPHIDs":[],
- *   "uri":"https://phab-01.wmflabs.org/T47",
- *   "auxiliary":{
- *     "std:maniphest:security_topic":"default",
- *     "isdc:sprint:storypoints":null
- *   },
- *   "objectName":"T47",
- *   "dateCreated":"1413484594",
- *   "dateModified":1413549869,
- *   "dependsOnTaskPHIDs":[]
- * }
- * </pre>
- */
-public class Task {
-  private int id;
-  private String phid;
-  private String authorPHID;
-  private String ownerPHID;
-  private JsonElement ccPHIDs;
-  private String status;
-  private String statusName;
-  private Boolean isClosed;
-  private String priority;
-  private String priorityColor;
-  private String title;
-  private String description;
-  private JsonElement projectPHIDs;
-  private String uri;
-  private JsonElement auxiliary;
-  private String objectName;
-  private String dateCreated;
-  private String dateModified;
-  private JsonElement dependsOnTaskPHIDs;
-  private JsonElement objectIdentifier;
-  private JsonElement transactions;
-
-  public int getId() {
-    return id;
-  }
-
-  public String getPhid() {
-    return phid;
-  }
-
-  public String getAuthorPHID() {
-    return authorPHID;
-  }
-
-  public String getOwnerPHID() {
-    return ownerPHID;
-  }
-
-  public JsonElement getCcPHIDs() {
-    return ccPHIDs;
-  }
-
-  public String getStatus() {
-    return status;
-  }
-
-  public String getStatusName() {
-    return statusName;
-  }
-
-  public Boolean getIsClosed() {
-    return isClosed;
-  }
-
-  public String getPriority() {
-    return priority;
-  }
-
-  public String getPriorityColor() {
-    return priorityColor;
-  }
-
-  public String getTitle() {
-    return title;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public JsonElement getProjectPHIDs() {
-    return projectPHIDs;
-  }
-
-  public String getUri() {
-    return uri;
-  }
-
-  public JsonElement getAuxiliary() {
-    return auxiliary;
-  }
-
-  public String getObjectName() {
-    return objectName;
-  }
-
-  public String getDateCreated() {
-    return dateCreated;
-  }
-
-  public String getDateModified() {
-    return dateModified;
-  }
-
-  public JsonElement getDependsOnTaskPHIDs() {
-    return dependsOnTaskPHIDs;
-  }
-
-  public JsonElement getObjectIdentifier() {
-    return objectIdentifier;
-  }
-
-  public JsonElement getTransactions() {
-    return transactions;
-  }
-}
diff --git a/src/main/resources/Documentation/config-rulebase-plugin-actions.md b/src/main/resources/Documentation/config-rulebase-plugin-actions.md
index 0662ff1..9fb7bf7 100644
--- a/src/main/resources/Documentation/config-rulebase-plugin-actions.md
+++ b/src/main/resources/Documentation/config-rulebase-plugin-actions.md
@@ -7,6 +7,9 @@
 [`add-project`][action-add-project]
 : adds a project to the task
 
+[`remove-project`][action-remove-project]
+: removes a project from the task
+
 [basic-actions]: config-rulebase-common.html#actions
 
 [action-add-project]: #action-add-project
@@ -21,6 +24,18 @@
 
 adds the project `MyCoolProject` to the task.
 
+[action-remove-project]: #action-remove-project
+### <a name="action-remove-project">Action: remove-project</a>
+
+The `remove-project` action removes a project from the task. The first
+parameter is the project name to remove. So for example
+
+```
+  action = remove-project MyCoolProject
+```
+
+removes the project `MyCoolProject` from the task.
+
 
 [Back to @PLUGIN@ documentation index][index]
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacadeTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacadeTest.java
index c164390..31ec0a5 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacadeTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/PhabricatorItsFacadeTest.java
@@ -13,7 +13,17 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.its.phabricator;
 
-import static org.easymock.EasyMock.expect;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
 
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -21,78 +31,235 @@
 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.phabricator.conduit.Conduit;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.ConduitException;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestEdit;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestSearch;
+import java.io.IOException;
+import java.net.URL;
 import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
 
 public class PhabricatorItsFacadeTest extends LoggingMockingTestCase {
   private Injector injector;
   private Config serverConfig;
+  private Conduit conduit;
+  private Conduit.Factory conduitFactory;
 
+  @Test
   public void testCreateLinkForWebUiDifferentUrlAndText() {
-    mockUnconnectablePhabricator();
-
-    replayMocks();
-
     PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
     String actual = itsFacade.createLinkForWebui("Test-Url", "Test-Text");
 
-    assertEquals("[[Test-Url|Test-Text]]", actual);
+    assertThat(actual).isEqualTo("[[Test-Url|Test-Text]]");
+
+    verifyZeroInteractions(conduit);
   }
 
+  @Test
   public void testCreateLinkForWebUiSameUrlAndText() {
-    mockUnconnectablePhabricator();
-
-    replayMocks();
-
     PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
     String actual = itsFacade.createLinkForWebui("Test-Url", "Test-Url");
 
-    assertEquals("[[Test-Url]]", actual);
+    assertThat(actual).isEqualTo("[[Test-Url]]");
+
+    verifyZeroInteractions(conduit);
   }
 
+  @Test
   public void testCreateLinkForWebUiNullText() {
-    mockUnconnectablePhabricator();
-
-    replayMocks();
-
     PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
     String actual = itsFacade.createLinkForWebui("Test-Url", null);
 
-    assertEquals("[[Test-Url]]", actual);
+    assertThat(actual).isEqualTo("[[Test-Url]]");
+
+    verifyZeroInteractions(conduit);
   }
 
+  @Test
   public void testCreateLinkForWebUiEmptyText() {
-    mockUnconnectablePhabricator();
-
-    replayMocks();
-
     PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
     String actual = itsFacade.createLinkForWebui("Test-Url", "");
 
-    assertEquals("[[Test-Url]]", actual);
+    assertThat(actual).isEqualTo("[[Test-Url]]");
+
+    verifyZeroInteractions(conduit);
+  }
+
+  @Test
+  public void testAddCommentPlain() throws Exception {
+    ManiphestEdit result = new ManiphestEdit();
+    when(conduit.maniphestEdit(4711, "bar", null, null)).thenReturn(result);
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    itsFacade.addComment("4711", "bar");
+
+    verify(conduit).maniphestEdit(4711, "bar", null, null);
+    verifyNoMoreInteractions(conduit);
+
+    assertLogMessageContains("comment");
+  }
+
+  @Test
+  public void testAddCommentPlainNoNumber() throws Exception {
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    assertThrows(RuntimeException.class, () -> itsFacade.addComment("foo", "bar"));
+
+    verifyZeroInteractions(conduit);
+  }
+
+  @Test
+  public void testAddCommentConduitException() throws Exception {
+    when(conduit.maniphestEdit(4711, "bar", null, null)).thenThrow(new ConduitException());
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    assertThrows(IOException.class, () -> itsFacade.addComment("4711", "bar"));
+
+    verify(conduit).maniphestEdit(4711, "bar", null, null);
+    verifyNoMoreInteractions(conduit);
+  }
+
+  @Test
+  public void testAddRelatedLinkPlain() throws Exception {
+    ManiphestEdit result = new ManiphestEdit();
+    when(conduit.maniphestEdit(anyInt(), anyString(), isNull(), isNull())).thenReturn(result);
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    itsFacade.addRelatedLink("4711", new URL("http://related.example.org"), "description");
+
+    ArgumentCaptor<String> commentCapture = ArgumentCaptor.forClass(String.class);
+    verify(conduit).maniphestEdit(eq(4711), commentCapture.capture(), isNull(), isNull());
+    verifyNoMoreInteractions(conduit);
+
+    assertThat(commentCapture.getValue()).contains("[[http://related.example.org|description]]");
+
+    assertLogMessageContains("comment");
+  }
+
+  @Test
+  public void testExistsNumberExists() throws Exception {
+    when(conduit.maniphestSearch(4711)).thenReturn(new ManiphestSearch());
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    Boolean actual = itsFacade.exists("4711");
+
+    assertThat(actual).isTrue();
+
+    verify(conduit).maniphestSearch(4711);
+    verifyNoMoreInteractions(conduit);
+  }
+
+  @Test
+  public void testExistsNumberDoesNotExist() throws Exception {
+    when(conduit.maniphestSearch(4711)).thenReturn(null);
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    Boolean actual = itsFacade.exists("4711");
+
+    assertThat(actual).isFalse();
+
+    verify(conduit).maniphestSearch(4711);
+    verifyNoMoreInteractions(conduit);
+  }
+
+  @Test
+  public void testExistsNumberConduitException() throws Exception {
+    when(conduit.maniphestSearch(4711)).thenThrow(new ConduitException());
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    assertThrows(IOException.class, () -> itsFacade.exists("4711"));
+
+    verify(conduit).maniphestSearch(4711);
+    verifyNoMoreInteractions(conduit);
+  }
+
+  @Test
+  public void testExistsNoNumber() throws Exception {
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    assertThrows(RuntimeException.class, () -> itsFacade.exists("foo"));
+
+    verifyZeroInteractions(conduit);
+  }
+
+  @Test
+  public void testPerformActionNoNumber() throws Exception {
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    assertThrows(RuntimeException.class, () -> itsFacade.performAction("Foo", "add-project bar"));
+
+    verifyZeroInteractions(conduit);
+  }
+
+  @Test
+  public void testPerformActionAddProjectPlain() throws Exception {
+    when(conduit.maniphestEdit(4711, null, "bar", null)).thenReturn(new ManiphestEdit());
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    itsFacade.performAction("4711", "add-project bar");
+
+    verify(conduit).maniphestEdit(4711, null, "bar", null);
+    verifyNoMoreInteractions(conduit);
+  }
+
+  @Test
+  public void testPerformActionAddProjectConduitException() throws Exception {
+    when(conduit.maniphestEdit(4711, null, "bar", null)).thenThrow(new ConduitException());
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    assertThrows(IOException.class, () -> itsFacade.performAction("4711", "add-project bar"));
+
+    verify(conduit).maniphestEdit(4711, null, "bar", null);
+    verifyNoMoreInteractions(conduit);
+  }
+
+  @Test
+  public void testPerformActionRemoveProjectPlain() throws Exception {
+    when(conduit.maniphestEdit(4711, null, null, "bar")).thenReturn(new ManiphestEdit());
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    itsFacade.performAction("4711", "remove-project bar");
+
+    verify(conduit).maniphestEdit(4711, null, null, "bar");
+    verifyNoMoreInteractions(conduit);
+  }
+
+  @Test
+  public void testPerformActionRemoveProjectConduitException() throws Exception {
+    when(conduit.maniphestEdit(4711, null, null, "bar")).thenThrow(new ConduitException());
+
+    PhabricatorItsFacade itsFacade = createPhabricatorItsFacade();
+    assertThrows(IOException.class, () -> itsFacade.performAction("4711", "remove-project bar"));
+
+    verify(conduit).maniphestEdit(4711, null, null, "bar");
+    verifyNoMoreInteractions(conduit);
   }
 
   private PhabricatorItsFacade createPhabricatorItsFacade() {
     return injector.getInstance(PhabricatorItsFacade.class);
   }
 
-  private void mockUnconnectablePhabricator() {
-    expect(serverConfig.getString("its-phabricator", null, "url")).andReturn("<no-url>").anyTimes();
-    expect(serverConfig.getString("its-phabricator", null, "token")).andReturn("none").anyTimes();
-  }
-
   @Override
   public void setUp() throws Exception {
     super.setUp();
 
+    serverConfig = mock(Config.class);
+    conduitFactory = mock(Conduit.Factory.class);
+    conduit = mock(Conduit.class);
+
+    when(serverConfig.getString("its-phabricator", null, "url"))
+        .thenReturn("http://phab.example.org/");
+    when(serverConfig.getString("its-phabricator", null, "token")).thenReturn("cli-FOO");
+    when(conduitFactory.create("http://phab.example.org/", "cli-FOO")).thenReturn(conduit);
+
     injector = Guice.createInjector(new TestModule());
   }
 
   private class TestModule extends FactoryModule {
     @Override
     protected void configure() {
-      serverConfig = createMock(Config.class);
       bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(serverConfig);
       bind(String.class).annotatedWith(PluginName.class).toInstance("its-phabricator");
+      bind(Conduit.Factory.class).toInstance(conduitFactory);
     }
   }
 }
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
new file mode 100644
index 0000000..c5dfad1
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/ConduitTest.java
@@ -0,0 +1,348 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.googlesource.gerrit.plugins.its.phabricator.conduit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ConduitPing;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestEdit;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ManiphestSearch;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.ProjectSearch;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ConduitTest extends LoggingMockingTestCase {
+  private static final String URL = "urlFoo";
+  private static final String TOKEN = "tokenFoo";
+  private ConduitConnection.Factory conduitConnectionFactory;
+  private ConduitConnection conduitConnection;
+
+  @Override
+  @Before
+  public void setUp() throws Exception {
+    super.setUp();
+    conduitConnection = mock(ConduitConnection.class);
+    conduitConnectionFactory = mock(ConduitConnection.Factory.class);
+    when(conduitConnectionFactory.create(URL)).thenReturn(conduitConnection);
+  }
+
+  @Test
+  public void testConduitPingPass() throws Exception {
+    JsonElement result = new JsonPrimitive("hostFoo");
+    Map<String, Object> params = new HashMap<>();
+    when(conduitConnection.call("conduit.ping", params, TOKEN)).thenReturn(result);
+
+    Conduit conduit = createConduit();
+
+    ConduitPing actual = conduit.conduitPing();
+    assertThat(actual.getHostname()).isEqualTo("hostFoo");
+  }
+
+  @Test
+  public void testConduitPingConnectionFail() throws Exception {
+    ConduitException e = new ConduitException();
+
+    Map<String, Object> params = new HashMap<>();
+    when(conduitConnection.call("conduit.ping", params, TOKEN)).thenThrow(e);
+
+    Conduit conduit = createConduit();
+
+    assertThrows(ConduitException.class, () -> conduit.conduitPing());
+  }
+
+  @Test
+  public void testConnectionReuse() throws Exception {
+    JsonElement result1 = new JsonPrimitive("hostFoo");
+    JsonElement result2 = new JsonPrimitive("hostBar");
+    Map<String, Object> params = new HashMap<>();
+    when(conduitConnection.call("conduit.ping", params, TOKEN)).thenReturn(result1, result2);
+
+    Conduit conduit = createConduit();
+
+    ConduitPing actual1 = conduit.conduitPing();
+    assertThat(actual1.getHostname()).isEqualTo("hostFoo");
+    ConduitPing actual2 = conduit.conduitPing();
+    assertThat(actual2.getHostname()).isEqualTo("hostBar");
+
+    verify(conduitConnectionFactory).create(URL);
+    verifyNoMoreInteractions(conduitConnectionFactory);
+  }
+
+  @Test
+  public void testProjectSearchPass() throws Exception {
+    Map<String, Object> params = new HashMap<>();
+    params.put("constraints", ImmutableMap.of("query", "foo"));
+
+    JsonArray data = new JsonArray();
+    data.add(createProjectJson(2, "foo"));
+
+    JsonObject result = new JsonObject();
+    result.add("data", data);
+
+    when(conduitConnection.call("project.search", params, TOKEN)).thenReturn(result);
+
+    Conduit conduit = createConduit();
+
+    ProjectSearch actual = conduit.projectSearch("foo");
+    assertThat(actual.getPhid()).isEqualTo("PHID-PROJ-foo");
+  }
+
+  @Test
+  public void testProjectSearchNotFound() throws Exception {
+    Map<String, Object> params = new HashMap<>();
+    params.put("constraints", ImmutableMap.of("query", "foo"));
+
+    JsonObject result = new JsonObject();
+    result.add("data", new JsonArray());
+
+    when(conduitConnection.call("project.search", params, TOKEN)).thenReturn(result);
+
+    Conduit conduit = createConduit();
+
+    ProjectSearch actual = conduit.projectSearch("foo");
+    assertThat(actual).isNull();
+  }
+
+  @Test
+  public void testManiphestEditNoop() throws Exception {
+    Conduit conduit = createConduit();
+    ManiphestEdit actual = conduit.maniphestEdit(4711, null, null, null);
+
+    verifyZeroInteractions(conduitConnection);
+    assertThat(actual).isNull();
+  }
+
+  @Test
+  public void testManiphestEditEmpty() throws Exception {
+    Conduit conduit = createConduit();
+    ManiphestEdit actual = conduit.maniphestEdit(4711, "", "", "");
+
+    verifyZeroInteractions(conduitConnection);
+    assertThat(actual).isNull();
+  }
+
+  @Test
+  public void testManiphestEditAddComment() throws Exception {
+    Map<String, Object> transaction = new HashMap<>();
+    transaction.put("type", "comment");
+    transaction.put("value", "foo");
+
+    Map<String, Object> params = new HashMap<>();
+    params.put("objectIdentifier", 4711);
+    params.put("transactions", ImmutableList.of(transaction));
+
+    JsonArray data = new JsonArray();
+    data.add(createProjectJson(2, "foo"));
+
+    JsonObject response = createEditResponse(1);
+    when(conduitConnection.call("maniphest.edit", params, TOKEN)).thenReturn(response);
+
+    Conduit conduit = createConduit();
+
+    ManiphestEdit actual = conduit.maniphestEdit(4711, "foo", null, null);
+
+    assertThat(actual.getObject().getId()).isEqualTo(4712);
+    assertThat(actual.getObject().getPhid()).isEqualTo("PHID-foo");
+    assertThat(actual.getTransactions()).hasSize(1);
+    assertThat(actual.getTransactions().get(0).getPhid()).isEqualTo("trans@0");
+  }
+
+  @Test
+  public void testManiphestEditAddProject() throws Exception {
+    Map<String, Object> transaction = new HashMap<>();
+    transaction.put("type", "projects.add");
+    transaction.put("value", ImmutableList.of("PHID-bar"));
+
+    Map<String, Object> params = new HashMap<>();
+    params.put("objectIdentifier", 4711);
+    params.put("transactions", ImmutableList.of(transaction));
+
+    JsonArray data = new JsonArray();
+    data.add(createProjectJson(2, "foo"));
+
+    JsonObject response = createEditResponse(1);
+    when(conduitConnection.call("maniphest.edit", params, TOKEN)).thenReturn(response);
+
+    Conduit conduit = spy(createConduit());
+
+    // shortcut the needed project search
+    doReturn(new ProjectSearch("PHID-bar", 12)).when(conduit).projectSearch("foo");
+
+    ManiphestEdit actual = conduit.maniphestEdit(4711, null, "foo", null);
+
+    assertThat(actual.getObject().getId()).isEqualTo(4712);
+    assertThat(actual.getObject().getPhid()).isEqualTo("PHID-foo");
+    assertThat(actual.getTransactions()).hasSize(1);
+    assertThat(actual.getTransactions().get(0).getPhid()).isEqualTo("trans@0");
+  }
+
+  @Test
+  public void testManiphestEditRemoveProject() throws Exception {
+    Map<String, Object> transaction = new HashMap<>();
+    transaction.put("type", "projects.remove");
+    transaction.put("value", ImmutableList.of("PHID-bar"));
+
+    Map<String, Object> params = new HashMap<>();
+    params.put("objectIdentifier", 4711);
+    params.put("transactions", ImmutableList.of(transaction));
+
+    JsonArray data = new JsonArray();
+    data.add(createProjectJson(2, "foo"));
+
+    JsonObject response = createEditResponse(1);
+    when(conduitConnection.call("maniphest.edit", params, TOKEN)).thenReturn(response);
+
+    Conduit conduit = spy(createConduit());
+
+    // shortcut the needed project search
+    doReturn(new ProjectSearch("PHID-bar", 12)).when(conduit).projectSearch("foo");
+
+    ManiphestEdit actual = conduit.maniphestEdit(4711, null, null, "foo");
+
+    assertThat(actual.getObject().getId()).isEqualTo(4712);
+    assertThat(actual.getObject().getPhid()).isEqualTo("PHID-foo");
+    assertThat(actual.getTransactions()).hasSize(1);
+    assertThat(actual.getTransactions().get(0).getPhid()).isEqualTo("trans@0");
+  }
+
+  @Test
+  public void testManiphestEditAllParams() throws Exception {
+    Map<String, Object> transaction1 = new HashMap<>();
+    transaction1.put("type", "comment");
+    transaction1.put("value", "foo");
+
+    Map<String, Object> transaction2 = new HashMap<>();
+    transaction2.put("type", "projects.add");
+    transaction2.put("value", ImmutableList.of("PHID-bar"));
+
+    Map<String, Object> transaction3 = new HashMap<>();
+    transaction3.put("type", "projects.remove");
+    transaction3.put("value", ImmutableList.of("PHID-baz"));
+
+    Map<String, Object> params = new HashMap<>();
+    params.put("objectIdentifier", 4711);
+    params.put("transactions", ImmutableList.of(transaction1, transaction2, transaction3));
+
+    JsonArray data = new JsonArray();
+    data.add(createProjectJson(2, "foo"));
+
+    JsonObject response = createEditResponse(3);
+    when(conduitConnection.call("maniphest.edit", params, TOKEN)).thenReturn(response);
+
+    Conduit conduit = spy(createConduit());
+
+    // shortcut the needed project searches
+    doReturn(new ProjectSearch("PHID-bar", 12)).when(conduit).projectSearch("bar");
+    doReturn(new ProjectSearch("PHID-baz", 13)).when(conduit).projectSearch("baz");
+
+    ManiphestEdit actual = conduit.maniphestEdit(4711, "foo", "bar", "baz");
+
+    assertThat(actual.getObject().getId()).isEqualTo(4712);
+    assertThat(actual.getObject().getPhid()).isEqualTo("PHID-foo");
+    assertThat(actual.getTransactions()).hasSize(3);
+    assertThat(actual.getTransactions().get(0).getPhid()).isEqualTo("trans@0");
+    assertThat(actual.getTransactions().get(1).getPhid()).isEqualTo("trans@1");
+    assertThat(actual.getTransactions().get(2).getPhid()).isEqualTo("trans@2");
+  }
+
+  @Test
+  public void testManiphestSearchNotFound() throws Exception {
+    Map<String, Object> params = new HashMap<>();
+    params.put("constraints", ImmutableMap.of("ids", ImmutableList.of(4711)));
+
+    JsonObject result = new JsonObject();
+    result.add("data", new JsonArray());
+
+    when(conduitConnection.call("maniphest.search", params, TOKEN)).thenReturn(result);
+    Conduit conduit = createConduit();
+
+    verifyNoMoreInteractions(conduitConnection);
+    ManiphestSearch actual = conduit.maniphestSearch(4711);
+    assertThat(actual).isNull();
+  }
+
+  @Test
+  public void testManiphestSearchPass() throws Exception {
+    Map<String, Object> params = new HashMap<>();
+    params.put("constraints", ImmutableMap.of("ids", ImmutableList.of(4711)));
+
+    JsonObject needle = new JsonObject();
+    needle.addProperty("id", 23);
+
+    JsonArray data = new JsonArray();
+    data.add(needle);
+
+    JsonObject result = new JsonObject();
+    result.add("data", data);
+
+    when(conduitConnection.call("maniphest.search", params, TOKEN)).thenReturn(result);
+    Conduit conduit = createConduit();
+
+    ManiphestSearch actual = conduit.maniphestSearch(4711);
+    assertThat(actual.getId()).isEqualTo(23);
+  }
+
+  private JsonObject createEditResponse(int transactions) {
+    JsonObject resultObject = new JsonObject();
+    resultObject.addProperty("id", 4712);
+    resultObject.addProperty("phid", "PHID-foo");
+
+    JsonArray transactionArray = new JsonArray();
+    for (int i = 0; i < transactions; i++) {
+      JsonObject transaction = new JsonObject();
+      transaction.addProperty("phid", "trans@" + i);
+      transactionArray.add(transaction);
+    }
+
+    JsonObject response = new JsonObject();
+    response.add("object", resultObject);
+    response.add("transactions", transactionArray);
+
+    return response;
+  }
+
+  private JsonObject createProjectJson(int id, String name) {
+    JsonObject fields = new JsonObject();
+    fields.addProperty("name", name);
+    fields.addProperty("slug", name);
+
+    JsonObject ret = new JsonObject();
+    ret.addProperty("id", id);
+    ret.addProperty("type", "PROJ");
+    ret.addProperty("phid", "PHID-PROJ-" + name);
+    ret.add("fields", fields);
+    return ret;
+  }
+
+  private Conduit createConduit() {
+    return new Conduit(conduitConnectionFactory, new SearchUtils(), URL, TOKEN);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/SearchUtilsTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/SearchUtilsTest.java
new file mode 100644
index 0000000..676aed5
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/phabricator/conduit/SearchUtilsTest.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2020 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Lists;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.googlesource.gerrit.plugins.its.base.testutil.LoggingMockingTestCase;
+import com.googlesource.gerrit.plugins.its.phabricator.conduit.results.PhabObject;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.Test;
+
+public class SearchUtilsTest extends LoggingMockingTestCase {
+  @Test
+  public void testStreamEmpty() throws Exception {
+    JsonObject searchResult = new JsonObject();
+    searchResult.add("data", new JsonArray());
+
+    SearchUtils searchUtils = createSearchUtils();
+    Stream<PhabObject> stream = searchUtils.stream(searchResult, PhabObject.class);
+    List<String> streamAsPhidList = Lists.newArrayList(stream.map(o -> o.getPhid()).iterator());
+    assertThat(streamAsPhidList).isEmpty();
+  }
+
+  @Test
+  public void testStreamSingle() throws Exception {
+    JsonArray data = new JsonArray();
+    data.add(createJsonOfPhabObject("PHID1"));
+
+    JsonObject searchResult = new JsonObject();
+    searchResult.add("data", data);
+
+    SearchUtils searchUtils = createSearchUtils();
+    Stream<PhabObject> stream = searchUtils.stream(searchResult, PhabObject.class);
+    List<String> streamAsPhidList = Lists.newArrayList(stream.map(o -> o.getPhid()).iterator());
+    assertThat(streamAsPhidList).containsExactly("PHID1");
+  }
+
+  @Test
+  public void testStreamMultiple() throws Exception {
+    JsonArray data = new JsonArray();
+    data.add(createJsonOfPhabObject("PHID1"));
+    data.add(createJsonOfPhabObject("PHID2"));
+    data.add(createJsonOfPhabObject("PHID3"));
+
+    JsonObject searchResult = new JsonObject();
+    searchResult.add("data", data);
+
+    SearchUtils searchUtils = createSearchUtils();
+    Stream<PhabObject> stream = searchUtils.stream(searchResult, PhabObject.class);
+    List<String> streamAsPhidList = Lists.newArrayList(stream.map(o -> o.getPhid()).iterator());
+    assertThat(streamAsPhidList).containsExactly("PHID1", "PHID2", "PHID3");
+  }
+
+  private SearchUtils createSearchUtils() {
+    return new SearchUtils();
+  }
+
+  private JsonObject createJsonOfPhabObject(String phid) {
+    JsonObject ret = new JsonObject();
+    ret.addProperty("phid", phid);
+
+    return ret;
+  }
+}