Support multi-word topics or topics with quotes/braces.

Currently, if there is a multi-word topic and someone tries
to submit, we error out with the missingDownstreamsMessage.
This is because we have a badly formatted query when we try
to see if there are any missing downstream branches in the
merge validator.

Instead, we should surround the topic in quotes or braces
so that we don't treat it as a separate query argument. We
do this by using the newly created QueryBuilder class to
construct our queries with escaped parameters for us.

Change-Id: I259df3cdabdc0b5e4f39794b5f976438d8e2a7ec
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/AutomergeChangeAction.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/AutomergeChangeAction.java
index d27638e..c51cd1a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/automerger/AutomergeChangeAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/AutomergeChangeAction.java
@@ -85,6 +85,9 @@
     } catch (ConfigInvalidException e) {
       throw new ResourceConflictException(
           "Automerger configuration file is invalid: " + e.getMessage());
+    } catch (InvalidQueryParameterException e) {
+      throw new ResourceConflictException(
+          "Topic or branch cannot have both braces and quotes: " + e.getMessage());      
     }
     return Response.none();
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigLoader.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigLoader.java
index 8691038..ad9530f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigLoader.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigLoader.java
@@ -217,7 +217,8 @@
     String message = getConfig().getString("global", null, "missingDownstreamsMessage");
     if (message == null) {
       message =
-          "Missing downstream branches ${missingDownstreams}. Please recreate the automerges.";
+          "Missing downstream branches ${missingDownstreams}. Please recreate the automerges. "
+              + "If your topic contains quotes or braces, please remove them.";
     }
     return message;
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
index 7d52446..4a4aba0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
@@ -125,8 +125,8 @@
           log.debug("Setting topic {} on {}", change.topic, changeNumber);
           gApi.changes().id(changeNumber).topic(change.topic);
         }
-      } catch (RestApiException e) {
-        log.error("RestApiException when editing downstream topics of {}", change.id, e);
+      } catch (RestApiException | InvalidQueryParameterException e) {
+        log.error("Failed to edit downstream topics of {}", change.id, e);
       }
     }
   }
@@ -172,7 +172,7 @@
             updateVote(downstreamChange, label.getKey(), label.getValue().value.shortValue());
           }
         }
-      } catch (RestApiException e) {
+      } catch (RestApiException | InvalidQueryParameterException e) {
         log.error("Exception when updating downstream votes of {}", change.id, e);
       }
     }
@@ -188,7 +188,10 @@
     ChangeInfo change = event.getChange();
     try {
       automergeChanges(change, event.getRevision());
-    } catch (RestApiException | IOException | ConfigInvalidException e) {
+    } catch (RestApiException
+        | IOException
+        | ConfigInvalidException
+        | InvalidQueryParameterException e) {
       log.error("Automerger plugin failed onChangeRestored for {}", change.id, e);
     }
   }
@@ -203,7 +206,10 @@
     ChangeInfo change = event.getChange();
     try {
       automergeChanges(change, event.getRevision());
-    } catch (RestApiException | IOException | ConfigInvalidException e) {
+    } catch (RestApiException
+        | IOException
+        | ConfigInvalidException
+        | InvalidQueryParameterException e) {
       log.error("Automerger plugin failed onDraftPublished for {}", change.id, e);
     }
   }
@@ -218,7 +224,10 @@
     ChangeInfo change = event.getChange();
     try {
       automergeChanges(change, event.getRevision());
-    } catch (RestApiException | IOException | ConfigInvalidException e) {
+    } catch (RestApiException
+        | IOException
+        | ConfigInvalidException
+        | InvalidQueryParameterException e) {
       log.error("Automerger plugin failed onRevisionCreated for {}", change.id, e);
     }
   }
@@ -229,9 +238,10 @@
    * @param mdsMergeInput Input containing the downstream branch map and source change ID.
    * @throws RestApiException Throws if we fail a REST API call.
    * @throws ConfigInvalidException Throws if we get a malformed configuration
+   * @throws InvalidQueryParameterException Throws if we attempt to add an invalid value to query.
    */
   public void createMergesAndHandleConflicts(MultipleDownstreamMergeInput mdsMergeInput)
-      throws RestApiException, ConfigInvalidException {
+      throws RestApiException, ConfigInvalidException, InvalidQueryParameterException {
     ReviewInput reviewInput = new ReviewInput();
     Map<String, Short> labels = new HashMap<String, Short>();
     try {
@@ -263,9 +273,11 @@
    * @throws RestApiException Throws if we fail a REST API call.
    * @throws FailedMergeException Throws if we get a merge conflict when merging downstream.
    * @throws ConfigInvalidException Throws if we get a malformed config file
+   * @throws InvalidQueryParameterException Throws if we attempt to add an invalid value to query.
    */
   public void createDownstreamMerges(MultipleDownstreamMergeInput mdsMergeInput)
-      throws RestApiException, FailedMergeException, ConfigInvalidException {
+      throws RestApiException, FailedMergeException, ConfigInvalidException,
+          InvalidQueryParameterException {
     // Map from branch to error message
     Map<String, String> failedMergeBranchMap = new TreeMap<String, String>();
 
@@ -337,15 +349,20 @@
    * @param downstreamBranch Branch to check for existing merge CLs.
    * @return List of change numbers that are downstream of the given branch.
    * @throws RestApiException Throws when we fail a REST API call.
+   * @throws InvalidQueryParameterException Throws when we try to add an invalid value to the query.
    */
   public List<Integer> getExistingMergesOnBranch(
-      String upstreamRevision, String topic, String downstreamBranch) throws RestApiException {
+      String upstreamRevision, String topic, String downstreamBranch)
+      throws RestApiException, InvalidQueryParameterException {
     List<Integer> downstreamChangeNumbers = new ArrayList<Integer>();
+    QueryBuilder queryBuilder = new QueryBuilder();
+    queryBuilder.addParameter("topic", topic);
+    queryBuilder.addParameter("branch", downstreamBranch);
+    queryBuilder.addParameter("status", "open");
     // get changes in same topic and check if their parent is upstreamRevision
-    String query = "topic:" + topic + " status:open branch:" + downstreamBranch;
     List<ChangeInfo> changes =
         gApi.changes()
-            .query(query)
+            .query(queryBuilder.get())
             .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
             .get();
 
@@ -406,7 +423,7 @@
   }
 
   private void automergeChanges(ChangeInfo change, RevisionInfo revisionInfo)
-      throws RestApiException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException, InvalidQueryParameterException {
     if (revisionInfo.draft != null && revisionInfo.draft) {
       log.debug("Patchset {} is draft change, ignoring.", revisionInfo.commit.commit);
       return;
@@ -462,7 +479,7 @@
           abandonChange(changeNumber);
         }
       }
-    } catch (RestApiException | IOException e) {
+    } catch (RestApiException | IOException | InvalidQueryParameterException e) {
       log.error("Failed to abandon downstreams of {}", change.id, e);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/InvalidQueryParameterException.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/InvalidQueryParameterException.java
new file mode 100644
index 0000000..8915f96
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/InvalidQueryParameterException.java
@@ -0,0 +1,22 @@
+// 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.automerger;
+
+/** Exception class for invalid query parameters. */
+class InvalidQueryParameterException extends Exception {
+
+  InvalidQueryParameterException(String message) {
+    super(message);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/MergeValidator.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/MergeValidator.java
index 664475d..60a4818 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/automerger/MergeValidator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/MergeValidator.java
@@ -76,7 +76,10 @@
       if (!missingDownstreams.isEmpty()) {
         throw new MergeValidationException(getMissingDownstreamsMessage(missingDownstreams));
       }
-    } catch (RestApiException | IOException | ConfigInvalidException e) {
+    } catch (RestApiException
+        | IOException
+        | ConfigInvalidException
+        | InvalidQueryParameterException e) {
       log.error("Automerger plugin failed onPreMerge for {}", changeId, e);
       e.printStackTrace();
       throw new MergeValidationException("Error when validating merge for: " + changeId);
@@ -98,17 +101,20 @@
 
   @VisibleForTesting
   protected Set<String> getMissingDownstreamMerges(ChangeInfo upstreamChange)
-      throws RestApiException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException, InvalidQueryParameterException {
     Set<String> missingDownstreamBranches = new HashSet<>();
 
     Set<String> downstreamBranches =
         config.getDownstreamBranches(upstreamChange.branch, upstreamChange.project);
     for (String downstreamBranch : downstreamBranches) {
       boolean dsExists = false;
-      String query = "topic:" + upstreamChange.topic + " status:open branch:" + downstreamBranch;
+      QueryBuilder queryBuilder = new QueryBuilder();
+      queryBuilder.addParameter("topic", upstreamChange.topic);
+      queryBuilder.addParameter("branch", downstreamBranch);
+      queryBuilder.addParameter("status", "open");
       List<ChangeInfo> changes =
           gApi.changes()
-              .query(query)
+              .query(queryBuilder.get())
               .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
               .get();
       for (ChangeInfo change : changes) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/QueryBuilder.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/QueryBuilder.java
new file mode 100644
index 0000000..fae69b4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/QueryBuilder.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 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.automerger;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Class to construct a query with escaped arguments. */
+public class QueryBuilder {
+  private Map<String, String> queryStringMap;
+
+  public QueryBuilder() {
+    this.queryStringMap = new HashMap<String, String>();
+  }
+  
+  public void addParameter(String key, String value) throws InvalidQueryParameterException {
+    if (value.contains("\"") && (value.contains("{") || value.contains("}"))) {
+      // Gerrit does not support search string escaping as of 5/16/2017
+      // see https://bugs.chromium.org/p/gerrit/issues/detail?id=5617
+      throw new InvalidQueryParameterException(
+          "Gerrit does not support both quotes and braces in a query.");
+    } else if (value.contains("\"")) {
+      queryStringMap.put(key, "{" + value + "}");
+    } else {
+      queryStringMap.put(key, "\"" + value + "\"");
+    }
+  }
+  
+  public String removeParameter(String key) {
+    return queryStringMap.remove(key);
+  }
+
+  public String get() {
+    List<String> queryStringList = new ArrayList<String>();
+    for (Map.Entry<String, String> entry : queryStringMap.entrySet()) {
+      queryStringList.add(entry.getKey() + ":" + entry.getValue());
+    }
+    return String.join(" ", queryStringList);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java b/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java
index fe9f8ab..ab90ba8 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorIT.java
@@ -58,7 +58,7 @@
     String projectName = result.getChange().project().get();
     createBranch(new Branch.NameKey(projectName, "ds_one"));
     createBranch(new Branch.NameKey(projectName, "ds_two"));
-    pushConfig("automerger.config", manifestNameKey.get(), projectName);
+    pushConfig("automerger.config", manifestNameKey.get(), projectName, "ds_one", "ds_two");
     // After we upload our config, we upload a new patchset to create the downstreams
     amendChange(result.getChangeId());
     result.assertOkStatus();
@@ -99,7 +99,7 @@
     String projectName = result.getChange().project().get();
     createBranch(new Branch.NameKey(projectName, "ds_one"));
     createBranch(new Branch.NameKey(projectName, "ds_two"));
-    pushConfig("automerger.config", manifestNameKey.get(), projectName);
+    pushConfig("automerger.config", manifestNameKey.get(), projectName, "ds_one", "ds_two");
     // After we upload our config, we upload a new patchset to create the downstreams
     amendChange(result.getChangeId(), "DO NOT MERGE subject", "filename", "content");
     result.assertOkStatus();
@@ -149,7 +149,7 @@
     String projectName = result.getChange().project().get();
     createBranch(new Branch.NameKey(projectName, "ds_one"));
     createBranch(new Branch.NameKey(projectName, "ds_two"));
-    pushConfig("automerger.config", manifestNameKey.get(), projectName);
+    pushConfig("automerger.config", manifestNameKey.get(), projectName, "ds_one", "ds_two");
     // After we upload our config, we upload a new patchset to create the downstreams
     amendChange(result.getChangeId(), "DO NOT MERGE ANYWHERE subject", "filename", "content");
     result.assertOkStatus();
@@ -209,7 +209,7 @@
     merge(ds1Result);
     // Reset to allow our merge conflict to come
     testRepo.reset(initial);
-    pushConfig("automerger.config", manifestNameKey.get(), projectName);
+    pushConfig("automerger.config", manifestNameKey.get(), projectName, "ds_one", "ds_two");
     // After we upload our config, we upload a new change to create the downstreams
     PushOneCommit.Result masterResult =
         pushFactory
@@ -240,6 +240,111 @@
     assertThat(getVote(dsTwoChange, "Code-Review").tag).isEqualTo("autogenerated:Automerger");
   }
 
+  @Test
+  public void testTopicEditedListener() throws Exception {
+    Project.NameKey manifestNameKey = defaultSetup();
+    // Create initial change
+    PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = result.getChange().project().get();
+    createBranch(new Branch.NameKey(projectName, "ds_one"));
+    createBranch(new Branch.NameKey(projectName, "ds_two"));
+    pushConfig("automerger.config", manifestNameKey.get(), projectName, "ds_one", "ds_two");
+    // After we upload our config, we upload a new patchset to create the downstreams
+    amendChange(result.getChangeId());
+    result.assertOkStatus();
+    gApi.changes().id(result.getChangeId()).topic("multiple words");
+    gApi.changes().id(result.getChangeId()).topic("singlewordagain");
+    // Check that there are the correct number of changes in the topic
+    List<ChangeInfo> changesInTopic =
+        gApi.changes()
+            .query("topic:\"" + gApi.changes().id(result.getChangeId()).topic() + "\"")
+            .get();
+    assertThat(changesInTopic).hasSize(3);
+    // +2 and submit
+    merge(result);
+  }
+
+  @Test
+  public void testTopicEditedListener_withQuotes() throws Exception {
+    Project.NameKey manifestNameKey = defaultSetup();
+    // Create initial change
+    PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = result.getChange().project().get();
+    createBranch(new Branch.NameKey(projectName, "ds_one"));
+    createBranch(new Branch.NameKey(projectName, "ds_two"));
+    pushConfig("automerger.config", manifestNameKey.get(), projectName, "ds_one", "ds_two");
+    // After we upload our config, we upload a new patchset to create the downstreams
+    amendChange(result.getChangeId());
+    result.assertOkStatus();
+    gApi.changes().id(result.getChangeId()).topic("multiple words");
+    gApi.changes().id(result.getChangeId()).topic("with\"quotes\"inside");
+    // Gerrit fails to submit changes in the same topic together if it contains quotes.
+    gApi.changes().id(result.getChangeId()).topic("without quotes anymore");
+    // Check that there are the correct number of changes in the topic
+    List<ChangeInfo> changesInTopic =
+        gApi.changes()
+            .query("topic:{" + gApi.changes().id(result.getChangeId()).topic() + "}")
+            .get();
+    assertThat(changesInTopic).hasSize(3);
+    // +2 and submit
+    merge(result);
+  }
+
+  @Test
+  public void testTopicEditedListener_withBraces() throws Exception {
+    Project.NameKey manifestNameKey = defaultSetup();
+    // Create initial change
+    PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = result.getChange().project().get();
+    createBranch(new Branch.NameKey(projectName, "ds_one"));
+    createBranch(new Branch.NameKey(projectName, "ds_two"));
+    pushConfig("automerger.config", manifestNameKey.get(), projectName, "ds_one", "ds_two");
+    // After we upload our config, we upload a new patchset to create the downstreams
+    amendChange(result.getChangeId());
+    result.assertOkStatus();
+    gApi.changes().id(result.getChangeId()).topic("multiple words");
+    gApi.changes().id(result.getChangeId()).topic("with{braces}inside");
+    // Check that there are the correct number of changes in the topic
+    List<ChangeInfo> changesInTopic =
+        gApi.changes()
+            .query("topic:\"" + gApi.changes().id(result.getChangeId()).topic() + "\"")
+            .get();
+    assertThat(changesInTopic).hasSize(3);
+    // +2 and submit
+    merge(result);
+  }
+
+  @Test
+  public void testTopicEditedListener_branchWithBracesAndQuotes() throws Exception {
+    Project.NameKey manifestNameKey = defaultSetup();
+    // Create initial change
+    PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = result.getChange().project().get();
+    createBranch(new Branch.NameKey(projectName, "branch{}braces"));
+    createBranch(new Branch.NameKey(projectName, "branch\"quotes"));
+    pushConfig(
+        "automerger.config",
+        manifestNameKey.get(),
+        projectName,
+        "branch{}braces",
+        "branch\"quotes");
+    // After we upload our config, we upload a new patchset to create the downstreams
+    amendChange(result.getChangeId());
+    result.assertOkStatus();
+    // Check that there are the correct number of changes in the topic
+    List<ChangeInfo> changesInTopic =
+        gApi.changes()
+            .query("topic:\"" + gApi.changes().id(result.getChangeId()).topic() + "\"")
+            .get();
+    assertThat(changesInTopic).hasSize(3);
+    // +2 and submit
+    merge(result);
+  }
+
   private Project.NameKey defaultSetup() throws Exception {
     Project.NameKey manifestNameKey = createProject("platform/manifest");
     setupTestRepo("default.xml", manifestNameKey, "master", "default.xml");
@@ -261,7 +366,8 @@
     }
   }
 
-  private void pushConfig(String resourceName, String manifestName, String project)
+  private void pushConfig(
+      String resourceName, String manifestName, String project, String branch1, String branch2)
       throws Exception {
     TestRepository<InMemoryRepository> allProjectRepo = cloneProject(allProjects, admin);
     GitUtil.fetch(allProjectRepo, RefNames.REFS_CONFIG + ":config");
@@ -274,8 +380,8 @@
       // Update manifest project path to the result of createProject(resourceName), since it is
       // scoped to the test method
       cfg.setString("global", null, "manifestProject", manifestName);
-      cfg.setString("automerger", "master:ds_one", "setProjects", project);
-      cfg.setString("automerger", "master:ds_two", "setProjects", project);
+      cfg.setString("automerger", "master:" + branch1, "setProjects", project);
+      cfg.setString("automerger", "master:" + branch2, "setProjects", project);
       PushOneCommit push =
           pushFactory.create(
               db, admin.getIdent(), allProjectRepo, "Subject", "automerger.config", cfg.toText());
diff --git a/src/test/java/com/googlesource/gerrit/plugins/automerger/MergeValidatorIT.java b/src/test/java/com/googlesource/gerrit/plugins/automerger/MergeValidatorIT.java
index dcbc4e8..54ff231 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/automerger/MergeValidatorIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/automerger/MergeValidatorIT.java
@@ -35,7 +35,7 @@
   sysModule = "com.googlesource.gerrit.plugins.automerger.AutomergerModule"
 )
 public class MergeValidatorIT extends LightweightPluginDaemonTest {
-  private void pushConfig(String resourceName, String project) throws Exception {
+  private void pushConfig(String resourceName, String project, String branch) throws Exception {
     TestRepository<InMemoryRepository> allProjectRepo = cloneProject(allProjects, admin);
     GitUtil.fetch(allProjectRepo, RefNames.REFS_CONFIG + ":config");
     allProjectRepo.reset("config");
@@ -46,7 +46,7 @@
       cfg.fromText(resourceString);
       // Update manifest project path to the result of createProject(resourceName), since it is
       // scoped to the test method
-      cfg.setString("automerger", "master:ds_one", "setProjects", project);
+      cfg.setString("automerger", "master:" + branch, "setProjects", project);
       PushOneCommit push =
           pushFactory.create(
               db, admin.getIdent(), allProjectRepo, "Subject", "automerger.config", cfg.toText());
@@ -61,7 +61,7 @@
     // Project name is scoped by test, so we need to get it from our initial change
     String projectName = result.getChange().change().getProject().get();
     createBranch(new Branch.NameKey(projectName, "ds_one"));
-    pushConfig("automerger.config", projectName);
+    pushConfig("automerger.config", projectName, "ds_one");
     // After we upload our config, we upload a new patchset to create the downstreams
     amendChange(result.getChangeId());
     result.assertOkStatus();
@@ -69,10 +69,55 @@
   }
 
   @Test
+  public void testNoMissingDownstreamMerges_branchWithQuotes() throws Exception {
+    // Create initial change
+    PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = result.getChange().change().getProject().get();
+    createBranch(new Branch.NameKey(projectName, "branch\"quotes"));
+    pushConfig("automerger.config", projectName, "branch\"quotes");
+    // After we upload our config, we upload a new patchset to create the downstreams
+    amendChange(result.getChangeId());
+    result.assertOkStatus();
+    merge(result);
+  }
+
+  @Test
+  public void testNoMissingDownstreamMerges_branchWithBraces() throws Exception {
+    // Create initial change
+    PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = result.getChange().change().getProject().get();
+    createBranch(new Branch.NameKey(projectName, "branch{}braces"));
+    pushConfig("automerger.config", projectName, "branch{}braces");
+    // After we upload our config, we upload a new patchset to create the downstreams
+    amendChange(result.getChangeId());
+    result.assertOkStatus();
+    merge(result);
+  }
+
+  @Test
+  public void testMultiWordTopic() throws Exception {
+    // Create initial change
+    PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
+    // Project name is scoped by test, so we need to get it from our initial change
+    String projectName = result.getChange().change().getProject().get();
+    createBranch(new Branch.NameKey(projectName, "ds_one"));
+    pushConfig("automerger.config", projectName, "ds_one");
+    // After we upload our config, we upload a new patchset to create the downstreams
+    amendChange(result.getChangeId());
+    result.assertOkStatus();
+    
+    gApi.changes().id(result.getChangeId()).topic("multiple words");
+    merge(result);
+    
+  }
+
+  @Test
   public void testMissingDownstreamMerges() throws Exception {
     // Create initial change
     PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
-    pushConfig("automerger.config", result.getChange().project().get());
+    pushConfig("automerger.config", result.getChange().project().get(), "ds_one");
     result.assertOkStatus();
     int changeNumber = result.getChange().getId().id;
     // Assert we are missing downstreams
@@ -88,7 +133,7 @@
   public void testMissingDownstreamMerges_custom() throws Exception {
     // Create initial change
     PushOneCommit.Result result = createChange("subject", "filename", "content", "testtopic");
-    pushConfig("alternate.config", result.getChange().project().get());
+    pushConfig("alternate.config", result.getChange().project().get(), "ds_one");
     result.assertOkStatus();
     int changeNumber = result.getChange().getId().id;
     // Assert we are missing downstreams
diff --git a/src/test/java/com/googlesource/gerrit/plugins/automerger/QueryBuilderTest.java b/src/test/java/com/googlesource/gerrit/plugins/automerger/QueryBuilderTest.java
new file mode 100644
index 0000000..2179602
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/automerger/QueryBuilderTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2016 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.automerger;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import static com.google.common.truth.Truth.assertThat;
+
+public class QueryBuilderTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+  private QueryBuilder queryBuilder;
+
+  @Before
+  public void setUp() throws Exception {
+    queryBuilder = new QueryBuilder();
+  }
+
+  @Test
+  public void basicParseTest() throws Exception {
+    queryBuilder.addParameter("status", "open");
+    assertThat(queryBuilder.get()).isEqualTo("status:\"open\"");
+  }
+
+  @Test
+  public void removeParameterTest() throws Exception {
+    queryBuilder.addParameter("status", "open");
+    queryBuilder.addParameter("branch", "master");
+    queryBuilder.removeParameter("status");
+    assertThat(queryBuilder.get()).isEqualTo("branch:\"master\"");
+  }
+
+  @Test
+  public void escapeQuoteTest() throws Exception {
+    queryBuilder.addParameter("topic", "topic\"with\"quotes");
+    assertThat(queryBuilder.get()).isEqualTo("topic:{topic\"with\"quotes}");
+  }
+
+  @Test
+  public void escapeBraceTest() throws Exception {
+    queryBuilder.addParameter("topic", "topic{with}braces");
+    assertThat(queryBuilder.get()).isEqualTo("topic:\"topic{with}braces\"");
+  }
+
+  @Test
+  public void errorOnQuotesAndBracesTest() throws Exception {
+    exception.expect(InvalidQueryParameterException.class);
+    exception.expectMessage("Gerrit does not support both quotes and braces in a query.");
+    queryBuilder.addParameter("topic", "topic{\"with\"}both");
+  }
+}