Working prototype with tests.

When the plugin detects a patchset has been created, it will
merge downstream until it hits a merge conflict. On the
conflicting merge, it will vote -1 on a configurable label
and provide instructions to resolving the merge conflict.

Draft changes will be ignored until published.

The plugin will put all the auto-created changes in the
same topic as the original change (or create a topic if
none exists). If a user updates the topic, it will update
the topic of all the downstream merges.

If there are existing downstream merges from a previous
automerged patchset, it will update them all.

Admins can update the config.yaml file in tools/automerger
(this is configurable) in order to add merge paths and
do other configuration changes.

Javascript portion will add a button to optionally skip
or merge all downstream changes. It will also bold all
related changes in the same branch.

Change-Id: I89209ed29073f987a37d03e10dad85a1c5c530fb
diff --git a/.buckconfig b/.buckconfig
new file mode 100644
index 0000000..112615b
--- /dev/null
+++ b/.buckconfig
@@ -0,0 +1,10 @@
+[alias]
+  automerger = //:automerger
+  plugin = //:automerger
+[java]
+  src_roots = java, resources
+[project]
+  ignore = .git
+[cache]
+  mode = dir
+  dir = buck-out/cache
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..21cb77a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+/target
+/.buckversion
+/.classpath
+/.settings
+/.project
+/.buckd
+/buck-cache
+/buck-out
+/bucklets
+.buckversion
+.watchmanconfig
+/eclipse-out
diff --git a/BUCK b/BUCK
new file mode 100644
index 0000000..bde84db
--- /dev/null
+++ b/BUCK
@@ -0,0 +1,59 @@
+include_defs('//bucklets/gerrit_plugin.bucklet')
+include_defs('//bucklets/maven_jar.bucklet')
+
+gerrit_plugin(
+  name = 'automerger',
+  srcs = glob(['src/main/java/**/*.java']),
+  resources = glob(['src/main/**/*']),
+  manifest_entries = [
+    'Gerrit-PluginName: automerger',
+    'Gerrit-Module: com.googlesource.gerrit.plugins.automerger.Module',
+    'Gerrit-HttpModule: com.googlesource.gerrit.plugins.automerger.HttpModule',
+    'Implementation-Title: Automerger plugin',
+    'Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/automerger',
+  ],
+  deps = [
+    ':re2j',
+    ':yaml',
+  ],
+)
+
+define_license(name = 're2j')
+
+maven_jar(
+  name = 'yaml',
+  id = 'org.yaml:snakeyaml:1.17',
+  sha1 = '7a27ea250c5130b2922b86dea63cbb1cc10a660c',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'mockito',
+  id = 'org.mockito:mockito-all:1.10.19',
+  sha1 = '539df70269cc254a58cccc5d8e43286b4a73bf30',
+  license = 'DO_NOT_DISTRIBUTE',
+)
+
+maven_jar(
+  name = 're2j',
+  id = 'com.google.re2j:re2j:1.0',
+  sha1 = 'd24ac5f945b832d93a55343cd1645b1ba3eca7c3',
+  license = 're2j',
+  local_license = True,
+)
+
+java_test(
+  name = 'automerger_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  resources = glob(['src/test/resources/**/*']),
+  labels = ['automerger'],
+  deps = GERRIT_PLUGIN_API + GERRIT_TESTS + [
+    ':automerger__plugin',
+    ':mockito',
+  ],
+)
+
+java_library(
+  name = 'classpath',
+  deps = [':automerger__plugin'] + GERRIT_PLUGIN_API,
+)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..11069ed
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+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.
diff --git a/LICENSE-re2j b/LICENSE-re2j
new file mode 100644
index 0000000..b620ae6
--- /dev/null
+++ b/LICENSE-re2j
@@ -0,0 +1,32 @@
+This is a work derived from Russ Cox's RE2 in Go, whose license
+http://golang.org/LICENSE is as follows:
+
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+     notice, this list of conditions and the following disclaimer.
+
+   * Redistributions in binary form must reproduce the above copyright
+     notice, this list of conditions and the following disclaimer in
+     the documentation and/or other materials provided with the
+     distribution.
+
+   * Neither the name of Google Inc. nor the names of its contributors
+     may be used to endorse or promote products derived from this
+     software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/gerrit/BUCK b/lib/gerrit/BUCK
new file mode 100644
index 0000000..f346f2f
--- /dev/null
+++ b/lib/gerrit/BUCK
@@ -0,0 +1,20 @@
+include_defs('//bucklets/maven_jar.bucklet')
+
+VER = '2.14-SNAPSHOT'
+REPO = MAVEN_LOCAL
+
+maven_jar(
+  name = 'plugin-api',
+  id = 'com.google.gerrit:gerrit-plugin-api:' + VER,
+  license = 'Apache2.0',
+  attach_source = False,
+  repository = REPO,
+)
+
+maven_jar(
+  name = 'acceptance-framework',
+  id = 'com.google.gerrit:gerrit-acceptance-framework:' + VER,
+  license = 'Apache2.0',
+  attach_source = False,
+  repository = REPO,
+)
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/AutomergeChangeAction.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/AutomergeChangeAction.java
new file mode 100644
index 0000000..08a068c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/AutomergeChangeAction.java
@@ -0,0 +1,102 @@
+// 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 com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.events.EventFactory;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.Map;
+
+class AutomergeChangeAction
+    implements UiAction<RevisionResource>,
+        RestModifyView<RevisionResource, AutomergeChangeAction.Input> {
+  private static final Logger log = LoggerFactory.getLogger(AutomergeChangeAction.class);
+
+  private Provider<CurrentUser> user;
+  private ConfigLoader config;
+  private DownstreamCreator dsCreator;
+  private EventFactory eventFactory;
+
+  @Inject
+  AutomergeChangeAction(
+      Provider<CurrentUser> user,
+      ConfigLoader config,
+      DownstreamCreator dsCreator,
+      EventFactory eventFactory) {
+    this.user = user;
+    this.config = config;
+    this.dsCreator = dsCreator;
+    this.eventFactory = eventFactory;
+  }
+
+  @Override
+  public Object apply(RevisionResource rev, Input input)
+      throws RestApiException, FailedMergeException {
+    Map<String, Boolean> branchMap = input.branchMap;
+
+    Change change = rev.getChange();
+    String revision = rev.getPatchSet().getRevision().get();
+
+    MultipleDownstreamMergeInput mdsMergeInput = new MultipleDownstreamMergeInput();
+    mdsMergeInput.dsBranchMap = branchMap;
+    mdsMergeInput.sourceId = change.getKey().get();
+    mdsMergeInput.project = change.getProject().get();
+    mdsMergeInput.topic = change.getTopic();
+    mdsMergeInput.subject = change.getSubject();
+    mdsMergeInput.obsoleteRevision = revision;
+    mdsMergeInput.currentRevision = revision;
+
+    dsCreator.createMergesAndHandleConflicts(mdsMergeInput);
+    return Response.none();
+  }
+
+  @Override
+  public Description getDescription(RevisionResource resource) {
+    String project = resource.getProject().get();
+    String branch = resource.getChange().getDest().getShortName();
+    Description desc = new Description();
+    desc = desc.setLabel("Recreate automerges").setTitle("Recreate automerges downstream");
+    try {
+      if (config.getDownstreamBranches(branch, project).isEmpty()) {
+        desc = desc.setVisible(false);
+      } else {
+        desc = desc.setVisible(user.get() instanceof IdentifiedUser);
+      }
+    } catch (RestApiException | IOException e) {
+      log.error("Failed to recreate automerges for {} on {}", project, branch);
+      desc = desc.setVisible(false);
+    }
+    return desc;
+  }
+
+  static class Input {
+    Map<String, Boolean> branchMap;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigDownstreamAction.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigDownstreamAction.java
new file mode 100644
index 0000000..d0a8022
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigDownstreamAction.java
@@ -0,0 +1,62 @@
+// 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 com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+class ConfigDownstreamAction
+    implements RestModifyView<RevisionResource, ConfigDownstreamAction.Input> {
+  private static final Logger log = LoggerFactory.getLogger(ConfigDownstreamAction.class);
+
+  protected ConfigLoader config;
+
+  @Inject
+  public ConfigDownstreamAction(ConfigLoader config) {
+    this.config = config;
+  }
+
+  @Override
+  public Response<Map<String, Boolean>> apply(RevisionResource rev, Input input)
+      throws RestApiException, IOException {
+
+    String branchName = rev.getChange().getDest().getShortName();
+    String projectName = rev.getProject().get();
+
+    Set<String> downstreamBranches = config.getDownstreamBranches(branchName, projectName);
+    Map<String, Boolean> downstreamMap = new HashMap<>();
+    for (String downstreamBranch : downstreamBranches) {
+      boolean isSkipMerge = config.isSkipMerge(branchName, downstreamBranch, input.subject);
+      downstreamMap.put(downstreamBranch, !isSkipMerge);
+    }
+    return Response.created(downstreamMap);
+  }
+
+  static class Input {
+    @DefaultInput String subject;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigLoader.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigLoader.java
new file mode 100644
index 0000000..23c111e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/ConfigLoader.java
@@ -0,0 +1,202 @@
+// 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 com.google.common.base.Charsets;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class ConfigLoader {
+  private static final Logger log = LoggerFactory.getLogger(ConfigLoader.class);
+  public final String configProject;
+  public final String configProjectBranch;
+  public final String configFilename;
+  public final List<String> configOptionKeys;
+
+  protected GerritApi gApi;
+  private volatile LoadedConfig config;
+
+  @Inject
+  public ConfigLoader(GerritApi gApi) throws IOException {
+    this.gApi = gApi;
+
+    String configKeysPath = "/config/config_keys.yaml";
+    try (InputStreamReader streamReader =
+        new InputStreamReader(getClass().getResourceAsStream(configKeysPath), Charsets.UTF_8)) {
+
+      String automergerConfigYamlString = CharStreams.toString(streamReader);
+      Map automergerConfig = (Map) (new Yaml().load(automergerConfigYamlString));
+      configProject = (String) automergerConfig.get("config_project");
+      configProjectBranch = (String) automergerConfig.get("config_project_branch");
+      configFilename = (String) automergerConfig.get("config_filename");
+      configOptionKeys = (List<String>) automergerConfig.get("config_option_keys");
+
+      try {
+        loadConfig();
+      } catch (IOException | RestApiException e) {
+        log.error("Config failed to sync!", e);
+        config = new LoadedConfig();
+      }
+    }
+  }
+
+  public void loadConfig() throws IOException, RestApiException {
+    config =
+        new LoadedConfig(
+            gApi, configProject, configProjectBranch, configFilename, configOptionKeys);
+  }
+
+  // Returns true if matches DO NOT MERGE regex and merge_all is false
+  public boolean isSkipMerge(String fromBranch, String toBranch, String commitMessage) {
+    return config.isSkipMerge(fromBranch, toBranch, commitMessage);
+  }
+
+  public Map<String, Object> getConfig(String fromBranch, String toBranch) {
+    return config.getMergeConfig(fromBranch, toBranch);
+  }
+
+  public String getAutomergeLabel() {
+    return config.getAutomergeLabel();
+  }
+
+  public String getCodeReviewLabel() {
+    return config.getCodeReviewLabel();
+  }
+
+  public Set<String> getProjectsInScope(String fromBranch, String toBranch)
+      throws RestApiException, IOException {
+    try {
+      Set<String> projectSet = new HashSet<String>();
+
+      Set<String> fromProjectSet = getManifestProjects(fromBranch);
+      projectSet.addAll(fromProjectSet);
+
+      Set<String> toProjectSet = getManifestProjects(fromBranch, toBranch);
+      // Take intersection of project sets, unless one is empty.
+      if (projectSet.isEmpty()) {
+        projectSet = toProjectSet;
+      } else if (!toProjectSet.isEmpty()) {
+        projectSet.retainAll(toProjectSet);
+      }
+
+      // The lower the level a config is applied, the higher priority it has
+      // For example, a project ignored in the global config but added in the branch config will
+      // be added to the final project set, not ignored
+      applyConfig(projectSet, config.getGlobal());
+      applyConfig(projectSet, config.getMergeConfig(fromBranch));
+      applyConfig(projectSet, config.getMergeConfig(fromBranch, toBranch));
+
+      return projectSet;
+    } catch (RestApiException | IOException e) {
+      log.error("Error reading manifest for {}!", fromBranch, e);
+      throw e;
+    }
+  }
+
+  public Set<String> getDownstreamBranches(String fromBranch, String project)
+      throws RestApiException, IOException {
+    Set<String> downstreamBranches = new HashSet<String>();
+    Map<String, Map> fromBranchConfig = config.getMergeConfig(fromBranch);
+
+    if (fromBranchConfig != null) {
+      for (String key : fromBranchConfig.keySet()) {
+        if (!configOptionKeys.contains(key)) {
+          // If it's not a config option, then the key is the toBranch
+          Map<String, Object> toBranchConfig = (Map<String, Object>) fromBranchConfig.get(key);
+          Set<String> projectsInScope = getProjectsInScope(fromBranch, key);
+          if (projectsInScope.contains(project)) {
+            downstreamBranches.add(key);
+          }
+        }
+      }
+    }
+    return downstreamBranches;
+  }
+
+  // Returns overriden manifest config if specified, default if not
+  private Map<String, String> getManifestInfoFromConfig(Map<String, Object> configMap) {
+    if (configMap.containsKey("manifest")) {
+      return (Map<String, String>) configMap.get("manifest");
+    }
+    return config.getDefaultManifestInfo();
+  }
+
+  // Returns contents of manifest file for the given branch.
+  // If manifest does not exist, return empty set.
+  private Set<String> getManifestProjects(String fromBranch) throws RestApiException, IOException {
+    Map fromBranchConfig = config.getMergeConfig(fromBranch);
+    Map<String, String> manifestProjectInfo = getManifestInfoFromConfig(fromBranchConfig);
+    return getManifestProjectsForBranch(manifestProjectInfo, fromBranch);
+  }
+
+  // Returns contents of manifest file for the given branch pair
+  // If manifest does not exist, return empty set.
+  private Set<String> getManifestProjects(String fromBranch, String toBranch)
+      throws RestApiException, IOException {
+    Map<String, Object> toBranchConfig = config.getMergeConfig(fromBranch, toBranch);
+    Map<String, String> manifestProjectInfo = getManifestInfoFromConfig(toBranchConfig);
+    return getManifestProjectsForBranch(manifestProjectInfo, toBranch);
+  }
+
+  private Set<String> getManifestProjectsForBranch(
+      Map<String, String> manifestProjectInfo, String branch) throws RestApiException, IOException {
+    String manifestProject = manifestProjectInfo.get("project");
+    String manifestFile = manifestProjectInfo.get("file");
+    try {
+      BinaryResult manifestConfig =
+          gApi.projects().name(manifestProject).branch(branch).file(manifestFile);
+      ManifestReader manifestReader = new ManifestReader(branch, manifestConfig.asString());
+      return manifestReader.getProjects();
+    } catch (ResourceNotFoundException e) {
+      return new HashSet<>();
+    }
+  }
+
+  private void applyConfig(Set<String> projects, Map givenConfig) {
+    if (givenConfig.containsKey("set_projects")) {
+      List<String> setProjects = (ArrayList<String>) givenConfig.get("set_projects");
+      projects.clear();
+      projects.addAll(setProjects);
+      // if we set projects we can ignore the rest
+      return;
+    }
+    if (givenConfig.containsKey("add_projects")) {
+      List<String> addProjects = (List<String>) givenConfig.get("add_projects");
+      projects.addAll(addProjects);
+    }
+    if (givenConfig.containsKey("ignore_projects")) {
+      List<String> ignoreProjects = (List<String>) givenConfig.get("ignore_projects");
+      projects.removeAll(ignoreProjects);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
new file mode 100644
index 0000000..1aeef47
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreator.java
@@ -0,0 +1,445 @@
+// 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 com.google.common.base.Joiner;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RestoreInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeMergedListener;
+import com.google.gerrit.extensions.events.ChangeRestoredListener;
+import com.google.gerrit.extensions.events.DraftPublishedListener;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
+
+import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.events.Event;
+import com.google.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.List;
+import java.util.ArrayList;
+import java.util.UUID;
+
+public class DownstreamCreator
+    implements ChangeAbandonedListener,
+        ChangeMergedListener,
+        ChangeRestoredListener,
+        DraftPublishedListener,
+        RevisionCreatedListener,
+        TopicEditedListener {
+  private static final Logger log = LoggerFactory.getLogger(DownstreamCreator.class);
+
+  protected GerritApi gApi;
+  protected ConfigLoader config;
+
+  @Inject
+  public DownstreamCreator(GerritApi gApi, ConfigLoader config) {
+    this.gApi = gApi;
+    this.config = config;
+  }
+
+  private void loadConfig() throws IOException, RestApiException {
+    try {
+      config.loadConfig();
+    } catch (IOException | RestApiException e) {
+      log.error("Config failed to sync!", e);
+      throw e;
+    }
+  }
+
+  @Override
+  public void onChangeMerged(ChangeMergedListener.Event event) {
+    ChangeInfo change = event.getChange();
+    try {
+      if (change.project.equals(config.configProject)
+          && change.branch.equals(config.configProjectBranch)) {
+        loadConfig();
+      }
+    } catch (RestApiException | IOException e) {
+      log.error("Failed to reload config at {}", change.id, e);
+    }
+  }
+
+  @Override
+  public void onChangeAbandoned(ChangeAbandonedListener.Event event) {
+    ChangeInfo change = event.getChange();
+    String revision = event.getRevision().commit.commit;
+    log.info("Detected revision {} abandoned on {}.", revision, change.project);
+    abandonDownstream(change, revision);
+  }
+
+  private void abandonDownstream(ChangeInfo change, String revision) {
+    try {
+      Set<String> downstreamBranches = config.getDownstreamBranches(change.branch, change.project);
+      if (downstreamBranches.isEmpty()) {
+        log.info("Downstream branches of {} on {} are empty", change.branch, change.project);
+        return;
+      }
+
+      for (String downstreamBranch : downstreamBranches) {
+        List<Integer> existingDownstream =
+            getExistingMergesOnBranch(revision, change.topic, downstreamBranch);
+        log.info("Abandoning existing downstreams: {}", existingDownstream);
+        for (Integer changeNumber : existingDownstream) {
+          abandonChange(changeNumber);
+        }
+      }
+    } catch (RestApiException | IOException e) {
+      log.error("Failed to abandon downstreams of {}", change.id, e);
+    }
+  }
+
+  @Override
+  public void onTopicEdited(TopicEditedListener.Event event) {
+    ChangeInfo change = event.getChange();
+    String oldTopic = event.getOldTopic();
+    try {
+      String revision =
+          gApi.changes()
+              .id(change.id)
+              .get(EnumSet.of(ListChangesOption.CURRENT_REVISION))
+              .currentRevision;
+      Set<String> downstreamBranches = config.getDownstreamBranches(change.branch, change.project);
+
+      if (downstreamBranches.isEmpty()) {
+        log.info("Downstream branches of {} on {} are empty", change.branch, change.project);
+        return;
+      }
+
+      for (String downstreamBranch : downstreamBranches) {
+        List<Integer> existingDownstream =
+            getExistingMergesOnBranch(revision, oldTopic, downstreamBranch);
+        for (Integer changeNumber : existingDownstream) {
+          log.info("Setting topic {} on {}", change.topic, changeNumber);
+          gApi.changes().id(changeNumber).topic(change.topic);
+        }
+      }
+    } catch (RestApiException | IOException e) {
+      log.error("Failed to edit downstream topics of {}", change.id, e);
+    }
+  }
+
+  @Override
+  public void onChangeRestored(ChangeRestoredListener.Event event) {
+    ChangeInfo change = event.getChange();
+    try {
+      automergeChanges(change, event.getRevision());
+    } catch (RestApiException | IOException e) {
+      log.error("Failed to edit downstream topics of {}", change.id, e);
+    }
+  }
+
+  @Override
+  public void onDraftPublished(DraftPublishedListener.Event event) {
+    ChangeInfo change = event.getChange();
+    try {
+      automergeChanges(change, event.getRevision());
+    } catch (RestApiException | IOException e) {
+      log.error("Failed to edit downstream topics of {}", change.id, e);
+    }
+  }
+
+  @Override
+  public void onRevisionCreated(RevisionCreatedListener.Event event) {
+    ChangeInfo change = event.getChange();
+    try {
+      automergeChanges(change, event.getRevision());
+    } catch (RestApiException | IOException e) {
+      log.error("Failed to edit downstream topics of {}", change.id, e);
+    }
+  }
+
+  private void automergeChanges(ChangeInfo change, RevisionInfo revisionInfo)
+      throws RestApiException, IOException {
+    if (revisionInfo.draft != null && revisionInfo.draft) {
+      log.info("Patchset {} is draft change, ignoring.", revisionInfo.commit.commit);
+      return;
+    }
+
+    String currentRevision = revisionInfo.commit.commit;
+    log.info(
+        "Handling patchsetevent with change id {} and revision {}", change.id, currentRevision);
+
+    Set<String> downstreamBranches = config.getDownstreamBranches(change.branch, change.project);
+
+    if (downstreamBranches.isEmpty()) {
+      log.info("Downstream branches of {} on {} are empty", change.branch, change.project);
+      return;
+    }
+
+    // Map whether or not we should merge it or skip it for each downstream
+    Map<String, Boolean> dsBranchMap = new HashMap<String, Boolean>();
+    for (String downstreamBranch : downstreamBranches) {
+      boolean isSkipMerge = config.isSkipMerge(change.branch, downstreamBranch, change.subject);
+      dsBranchMap.put(downstreamBranch, !isSkipMerge);
+    }
+    log.info("Automerging change {} from branch {}", change.id, change.branch);
+
+    ChangeApi currentChange = gApi.changes().id(change._number);
+    String previousRevision = getPreviousRevision(currentChange, revisionInfo._number);
+
+    MultipleDownstreamMergeInput mdsMergeInput = new MultipleDownstreamMergeInput();
+    mdsMergeInput.dsBranchMap = dsBranchMap;
+    mdsMergeInput.sourceId = change.id;
+    mdsMergeInput.project = change.project;
+    mdsMergeInput.topic = change.topic;
+    mdsMergeInput.subject = change.subject;
+    mdsMergeInput.obsoleteRevision = previousRevision;
+    mdsMergeInput.currentRevision = currentRevision;
+
+    createMergesAndHandleConflicts(mdsMergeInput);
+  }
+
+  public void createMergesAndHandleConflicts(MultipleDownstreamMergeInput mdsMergeInput)
+      throws RestApiException {
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, Short> labels = new HashMap<String, Short>();
+    short vote = 0;
+    try {
+      createDownstreamMerges(mdsMergeInput);
+
+      reviewInput.message =
+          "Automerging to "
+              + Joiner.on(", ").join(mdsMergeInput.dsBranchMap.keySet())
+              + " succeeded!";
+      reviewInput.notify = NotifyHandling.NONE;
+      vote = 1;
+    } catch (FailedMergeException e) {
+      reviewInput.message = e.displayConflicts();
+      reviewInput.notify = NotifyHandling.ALL;
+      vote = -1;
+    }
+    labels.put(config.getAutomergeLabel(), vote);
+    reviewInput.labels = labels;
+    gApi.changes()
+        .id(mdsMergeInput.sourceId)
+        .revision(mdsMergeInput.currentRevision)
+        .review(reviewInput);
+  }
+
+  public void createDownstreamMerges(MultipleDownstreamMergeInput mdsMergeInput)
+      throws RestApiException, FailedMergeException {
+    Map<String, String> failedMerges = new HashMap<String, String>();
+
+    List<Integer> existingDownstream;
+    for (String downstreamBranch : mdsMergeInput.dsBranchMap.keySet()) {
+      // If there are existing downstream merges, update them
+      // Otherwise, create them.
+      try {
+        boolean createDownstreams = true;
+        if (mdsMergeInput.obsoleteRevision != null) {
+          existingDownstream =
+              getExistingMergesOnBranch(
+                  mdsMergeInput.obsoleteRevision, mdsMergeInput.topic, downstreamBranch);
+          if (!existingDownstream.isEmpty()) {
+            log.info(
+                "Attempting to update downstream merge of {} on branch {}",
+                mdsMergeInput.currentRevision,
+                downstreamBranch);
+            // existingDownstream should almost always be of length one, but
+            // it's possible to construct it so that it's not
+            for (Integer dsChangeNumber : existingDownstream) {
+              updateDownstreamMerge(
+                  mdsMergeInput.currentRevision,
+                  mdsMergeInput.subject,
+                  dsChangeNumber,
+                  mdsMergeInput.dsBranchMap.get(downstreamBranch));
+              createDownstreams = false;
+            }
+          }
+        }
+        if (createDownstreams) {
+          log.info(
+              "Attempting to create downstream merge of {} on branch {}",
+              mdsMergeInput.currentRevision,
+              downstreamBranch);
+          SingleDownstreamMergeInput sdsMergeInput = new SingleDownstreamMergeInput();
+          sdsMergeInput.currentRevision = mdsMergeInput.currentRevision;
+          sdsMergeInput.sourceId = mdsMergeInput.sourceId;
+          sdsMergeInput.project = mdsMergeInput.project;
+          sdsMergeInput.topic = mdsMergeInput.topic;
+          sdsMergeInput.subject = mdsMergeInput.subject;
+          sdsMergeInput.downstreamBranch = downstreamBranch;
+          sdsMergeInput.doMerge = mdsMergeInput.dsBranchMap.get(downstreamBranch);
+          createSingleDownstreamMerge(sdsMergeInput);
+        }
+      } catch (MergeConflictException e) {
+        log.info("Merge conflict from {} to {}", mdsMergeInput.currentRevision, downstreamBranch);
+        failedMerges.put(downstreamBranch, e.getMessage());
+        log.info("Abandoning downstream of {}", mdsMergeInput.sourceId);
+        abandonDownstream(
+            gApi.changes().id(mdsMergeInput.sourceId).info(), mdsMergeInput.currentRevision);
+      }
+    }
+
+    if (!failedMerges.keySet().isEmpty()) {
+      throw new FailedMergeException(failedMerges);
+    }
+  }
+
+  // get change ids of immediate downstream changes of the revision on branch
+  public List<Integer> getExistingMergesOnBranch(
+      String upstreamRevision, String topic, String downstreamBranch) throws RestApiException {
+    List<Integer> downstreamChangeNumbers = new ArrayList<Integer>();
+    // 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)
+            .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
+            .get();
+
+    for (ChangeInfo change : changes) {
+      String changeRevision = change.currentRevision;
+      RevisionInfo revision = change.revisions.get(changeRevision);
+      List<CommitInfo> parents = revision.commit.parents;
+      if (parents.size() > 1) {
+        String secondParent = parents.get(1).commit;
+        if (secondParent.equals(upstreamRevision)) {
+          downstreamChangeNumbers.add(change._number);
+        }
+      }
+    }
+    return downstreamChangeNumbers;
+  }
+
+  private void updateDownstreamMerge(
+      String newParentRevision, String upstreamSubject, Integer sourceNum, boolean doMerge)
+      throws RestApiException {
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = newParentRevision;
+
+    MergePatchSetInput mergePatchSetInput = new MergePatchSetInput();
+    mergePatchSetInput.subject = upstreamSubject + " am: " + newParentRevision.substring(0, 10);
+    if (!doMerge) {
+      mergeInput.strategy = "ours";
+      mergePatchSetInput.subject =
+          upstreamSubject + " skipped: " + newParentRevision.substring(0, 10);
+      log.info("Skipping merge for {} on {}", newParentRevision, sourceNum);
+    }
+    mergePatchSetInput.merge = mergeInput;
+
+    ChangeApi originalChange = gApi.changes().id(sourceNum);
+
+    if (originalChange.info().status == ChangeStatus.ABANDONED) {
+      RestoreInput restoreInput = new RestoreInput();
+      restoreInput.message = "Restoring change due to upstream automerge.";
+      originalChange.restore(restoreInput);
+    }
+
+    ChangeInfo updatedChange = originalChange.createMergePatchSet(mergePatchSetInput);
+    ChangeApi updatedChangeApi = gApi.changes().id(updatedChange.id);
+    givePlusTwo(updatedChangeApi);
+  }
+
+  public void createSingleDownstreamMerge(SingleDownstreamMergeInput sdsMergeInput)
+      throws RestApiException {
+
+    String currentTopic = setTopic(sdsMergeInput.sourceId, sdsMergeInput.topic);
+
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = sdsMergeInput.currentRevision;
+
+    log.info("Creating downstream merge for {}", sdsMergeInput.currentRevision);
+    ChangeInput downstreamChangeInput = new ChangeInput();
+    downstreamChangeInput.project = sdsMergeInput.project;
+    downstreamChangeInput.branch = sdsMergeInput.downstreamBranch;
+    downstreamChangeInput.subject =
+        sdsMergeInput.subject + " am: " + sdsMergeInput.currentRevision.substring(0, 10);
+    downstreamChangeInput.topic = currentTopic;
+    downstreamChangeInput.merge = mergeInput;
+
+    if (!sdsMergeInput.doMerge) {
+      mergeInput.strategy = "ours";
+      downstreamChangeInput.subject =
+          sdsMergeInput.subject + " skipped: " + sdsMergeInput.currentRevision.substring(0, 10);
+      log.info(
+          "Skipping merge for {} to {}",
+          sdsMergeInput.currentRevision,
+          sdsMergeInput.downstreamBranch);
+    }
+
+    ChangeApi newChangeApi = gApi.changes().create(downstreamChangeInput);
+    givePlusTwo(newChangeApi);
+  }
+
+  private void givePlusTwo(ChangeApi downstreamChange) throws RestApiException {
+    log.info("Giving +2 to {}", downstreamChange.id());
+    // Vote +2 on all downstream branches unless merge conflict.
+    ReviewInput reviewInput = new ReviewInput();
+    ChangeInfo newChange = downstreamChange.get(EnumSet.of(ListChangesOption.CURRENT_REVISION));
+    short codeReviewVote = 2;
+    Map<String, Short> labels = new HashMap<String, Short>();
+    labels.put(config.getCodeReviewLabel(), codeReviewVote);
+    reviewInput.labels = labels;
+    gApi.changes().id(newChange.id).revision(newChange.currentRevision).review(reviewInput);
+  }
+
+  private String getPreviousRevision(ChangeApi change, int currentPatchSetNumber)
+      throws RestApiException {
+    String previousRevision = null;
+    int maxPatchSetNum = 0;
+    if (currentPatchSetNumber > 1) {
+      // Get sha of patch set with highest number we can see
+      Map<String, RevisionInfo> revisionMap =
+          change.get(EnumSet.of(ListChangesOption.ALL_REVISIONS)).revisions;
+      for (Map.Entry<String, RevisionInfo> revisionEntry : revisionMap.entrySet()) {
+        int revisionPatchNumber = revisionEntry.getValue()._number;
+        if (revisionPatchNumber > maxPatchSetNum && revisionPatchNumber < currentPatchSetNumber) {
+          previousRevision = revisionEntry.getKey();
+          maxPatchSetNum = revisionPatchNumber;
+        }
+      }
+    }
+    return previousRevision;
+  }
+
+  private void abandonChange(Integer changeNumber) throws RestApiException {
+    log.info("Abandoning change: {}", changeNumber);
+    AbandonInput abandonInput = new AbandonInput();
+    abandonInput.notify = NotifyHandling.NONE;
+    abandonInput.message = "Merge parent updated; abandoning due to upstream conflict.";
+    gApi.changes().id(changeNumber).abandon(abandonInput);
+  }
+
+  private String setTopic(String sourceId, String topic) throws RestApiException {
+    if (topic == null || topic.isEmpty()) {
+      topic = "am-" + UUID.randomUUID().toString();
+      log.info("Setting original change {} topic to {}", sourceId, topic);
+      gApi.changes().id(sourceId).topic(topic);
+    }
+    return topic;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/FailedMergeException.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/FailedMergeException.java
new file mode 100644
index 0000000..78c59cc
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/FailedMergeException.java
@@ -0,0 +1,58 @@
+// 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 com.google.common.base.Joiner;
+
+import java.util.Map;
+
+class FailedMergeException extends Exception {
+  private static final int MAX_CONFLICT_MESSAGE_LENGTH = 10000;
+
+  public final Map<String, String> failedMerges;
+
+  FailedMergeException(Map<String, String> failedMerges) {
+    this.failedMerges = failedMerges;
+  }
+
+  public String displayConflicts() {
+    StringBuilder output = new StringBuilder();
+    output.append("Merge conflict found on ");
+    output.append(failedMergeKeys());
+    output.append(". Please follow instructions at go/resolveconflict ");
+    output.append("to resolve this merge conflict.\n\n");
+
+    for (Map.Entry<String, String> entry : failedMerges.entrySet()) {
+      String branch = entry.getKey();
+      String message = entry.getValue();
+      String conflictMessage = message;
+      boolean truncated = false;
+      if (message.length() > MAX_CONFLICT_MESSAGE_LENGTH) {
+        conflictMessage = message.substring(0, MAX_CONFLICT_MESSAGE_LENGTH);
+        truncated = true;
+      }
+      output.append(branch);
+      output.append(":\n");
+      output.append(conflictMessage);
+      if (truncated) {
+        output.append("...\n\n");
+      }
+    }
+    return output.toString();
+  }
+
+  public String failedMergeKeys() {
+    return Joiner.on(", ").join(failedMerges.keySet());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/HttpModule.java
new file mode 100644
index 0000000..a9d82fe
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/HttpModule.java
@@ -0,0 +1,26 @@
+// 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 com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
+
+public class HttpModule extends HttpPluginModule {
+  @Override
+  protected void configureServlets() {
+    DynamicSet.bind(binder(), WebUiPlugin.class).toInstance(new JavaScriptPlugin("automerger.js"));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/LoadedConfig.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/LoadedConfig.java
new file mode 100644
index 0000000..51ea025
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/LoadedConfig.java
@@ -0,0 +1,136 @@
+// 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 com.google.common.base.Joiner;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.re2j.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class LoadedConfig {
+  private static final Logger log = LoggerFactory.getLogger(LoadedConfig.class);
+
+  private final Map<String, Object> global;
+  private final Map<String, Map> config;
+  private final Map<String, String> defaultManifestInfo;
+  private final Pattern blankMergePattern;
+  private final Pattern alwaysBlankMergePattern;
+
+  public LoadedConfig() {
+    global = Collections.emptyMap();
+    config = Collections.emptyMap();
+    defaultManifestInfo = Collections.emptyMap();
+    blankMergePattern = Pattern.compile("");
+    alwaysBlankMergePattern = Pattern.compile("");
+  }
+
+  public LoadedConfig(
+      GerritApi gApi,
+      String configProject,
+      String configProjectBranch,
+      String configFilename,
+      List<String> configOptionKeys)
+      throws IOException, RestApiException {
+    log.info(
+        "Loading config file from project {} on branch {} and filename {}",
+        configProject,
+        configProjectBranch,
+        configFilename);
+    BinaryResult configFile =
+        gApi.projects().name(configProject).branch(configProjectBranch).file(configFilename);
+    String configFileString = configFile.asString();
+    config = (Map<String, Map>) (new Yaml().load(configFileString));
+    global = (Map<String, Object>) config.get("global");
+    defaultManifestInfo = (Map<String, String>) global.get("manifest");
+
+    blankMergePattern = getConfigPattern("blank_merge");
+    alwaysBlankMergePattern = getConfigPattern("always_blank_merge");
+    log.info("Finished syncing automerger config.");
+  }
+
+  private Pattern getConfigPattern(String key) {
+    Set<String> mergeStrings = new HashSet<String>((List<String>) global.get(key));
+    return Pattern.compile(Joiner.on("|").join(mergeStrings), Pattern.DOTALL);
+  }
+
+  public boolean isSkipMerge(String fromBranch, String toBranch, String commitMessage) {
+    // If regex matches always_blank_merge (DO NOT MERGE ANYWHERE), skip.
+    if (alwaysBlankMergePattern.matches(commitMessage)) {
+      return true;
+    }
+
+    // If regex matches blank_merge (DO NOT MERGE), skip iff merge_all is false
+    if (blankMergePattern.matches(commitMessage)) {
+      Map<String, Object> mergePairConfig = getMergeConfig(fromBranch, toBranch);
+      if (mergePairConfig != null) {
+        boolean isMergeAll = (boolean) mergePairConfig.getOrDefault("merge_all", false);
+        return !isMergeAll;
+      }
+    }
+    return false;
+  }
+
+  public Map<String, Map> getMergeConfig(String fromBranch) {
+    return getBranches().get(fromBranch);
+  }
+
+  public Map<String, Object> getMergeConfig(String fromBranch, String toBranch) {
+    Map<String, Map> fromBranchConfig = getBranches().get(fromBranch);
+    if (fromBranchConfig == null) {
+      return Collections.emptyMap();
+    }
+    return (Map<String, Object>) fromBranchConfig.get(toBranch);
+  }
+
+  public Map<String, Map> getBranches() {
+    return (Map<String, Map>) config.get("branches");
+  }
+
+  public Map<String, Object> getGlobal() {
+    return global;
+  }
+
+  public Map<String, String> getDefaultManifestInfo() {
+    return defaultManifestInfo;
+  }
+
+  public String getAutomergeLabel() {
+    return (String) global.getOrDefault("automerge_label", "Verified");
+  }
+
+  public String getCodeReviewLabel() {
+    return (String) global.getOrDefault("code_review_label", "Code-Review");
+  }
+
+  public Object getGlobalAttribute(String key) {
+    return global.get(key);
+  }
+
+  public Object getGlobalAttributeOrDefault(String key, Object def) {
+    return global.getOrDefault(key, def);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/ManifestReader.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/ManifestReader.java
new file mode 100644
index 0000000..70f3fcd
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/ManifestReader.java
@@ -0,0 +1,82 @@
+// 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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.HashSet;
+import java.util.Set;
+
+public class ManifestReader {
+  private static final Logger log = LoggerFactory.getLogger(ManifestReader.class);
+
+  private final String manifestString;
+  private final String branch;
+
+  public ManifestReader(String branch, String manifestString) {
+    this.manifestString = manifestString;
+    this.branch = branch;
+  }
+
+  public Set<String> getProjects() {
+    Set<String> projectSet = new HashSet<String>();
+
+    try {
+      DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
+      DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
+      Document document = documentBuilder.parse(new InputSource(new StringReader(manifestString)));
+
+      Element defaultElement = (Element) document.getElementsByTagName("default").item(0);
+      String defaultRevision = defaultElement.getAttribute("revision");
+
+      NodeList projectNodes = document.getElementsByTagName("project");
+      for (int i = 0; i < projectNodes.getLength(); i++) {
+        Node projectNode = projectNodes.item(i);
+        if (projectNode.getNodeType() == Node.ELEMENT_NODE) {
+          Element projectElement = (Element) projectNode;
+          String path = projectElement.getAttribute("path");
+          String name = projectElement.getAttribute("name");
+
+          String revision = projectElement.getAttribute("revision");
+          if ("".equals(revision)) {
+            revision = defaultRevision;
+          }
+
+          // Only add to list of projects in scope if revision is same as
+          // manifest branch
+          if (revision.equals(branch)) {
+            projectSet.add(name);
+          }
+        }
+      }
+
+    } catch (SAXException | ParserConfigurationException | IOException e) {
+      log.error("Exception on manifest for branch {}", branch, e);
+    }
+    return projectSet;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/Module.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/Module.java
new file mode 100644
index 0000000..951a7b4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/Module.java
@@ -0,0 +1,43 @@
+// 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 com.google.gerrit.extensions.events.*;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.inject.AbstractModule;
+
+import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+
+public class Module extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(DownstreamCreator.class);
+    DynamicSet.bind(binder(), ChangeMergedListener.class).to(DownstreamCreator.class);
+    DynamicSet.bind(binder(), ChangeRestoredListener.class).to(DownstreamCreator.class);
+    DynamicSet.bind(binder(), DraftPublishedListener.class).to(DownstreamCreator.class);
+    DynamicSet.bind(binder(), RevisionCreatedListener.class).to(DownstreamCreator.class);
+    DynamicSet.bind(binder(), TopicEditedListener.class).to(DownstreamCreator.class);
+    install(
+        new RestApiModule() {
+          @Override
+          protected void configure() {
+            post(REVISION_KIND, "automerge-change").to(AutomergeChangeAction.class);
+            post(REVISION_KIND, "config-downstream").to(ConfigDownstreamAction.class);
+          }
+        });
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/MultipleDownstreamMergeInput.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/MultipleDownstreamMergeInput.java
new file mode 100644
index 0000000..6bf7cdf
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/MultipleDownstreamMergeInput.java
@@ -0,0 +1,27 @@
+// 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.Map;
+
+public class MultipleDownstreamMergeInput {
+  public Map<String, Boolean> dsBranchMap;
+  public String sourceId;
+  public String project;
+  public String topic;
+  public String subject;
+  public String obsoleteRevision;
+  public String currentRevision;
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/automerger/SingleDownstreamMergeInput.java b/src/main/java/com/googlesource/gerrit/plugins/automerger/SingleDownstreamMergeInput.java
new file mode 100644
index 0000000..f565a7c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/automerger/SingleDownstreamMergeInput.java
@@ -0,0 +1,25 @@
+// 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;
+
+public class SingleDownstreamMergeInput {
+  public String currentRevision;
+  public String sourceId;
+  public String project;
+  public String topic;
+  public String subject;
+  public String downstreamBranch;
+  public boolean doMerge;
+}
diff --git a/src/main/resources/Documentation/about.md b/src/main/resources/Documentation/about.md
new file mode 100644
index 0000000..dc9aa32
--- /dev/null
+++ b/src/main/resources/Documentation/about.md
@@ -0,0 +1,21 @@
+Plugin to allow automatically merging changes from one branch to another based
+on a config file.
+
+When the plugin detects a patchset has been created, it will
+merge downstream until it hits a merge conflict. On the
+conflicting merge, it will vote -1 on a configurable label
+and provide instructions to resolving the merge conflict.
+
+Draft changes will be ignored until published.
+
+The plugin will put all the auto-created changes in the
+same topic as the original change (or create a topic if
+none exists). If a user updates the topic, it will update
+the topic of all the downstream merges.
+
+If there are existing downstream merges from a previous
+automerged patchset, it will update them all.
+
+A UI button "Recreate automerges" has been added so that users can skip
+downstream merges. Unchecking a branch's checkbox will skip that branch and
+all automerges downstream of that branch.
\ No newline at end of file
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md
new file mode 100644
index 0000000..24145f3
--- /dev/null
+++ b/src/main/resources/Documentation/build.md
@@ -0,0 +1,74 @@
+Build
+=====
+
+This plugin can be built with Buck.
+
+Buck
+----
+
+Two build modes are supported: Standalone and in Gerrit tree.
+The standalone build mode is recommended, as this mode doesn't require
+the Gerrit tree to exist locally.
+
+
+### Build standalone
+
+Clone bucklets library:
+
+```
+  git clone https://gerrit.googlesource.com/bucklets
+
+```
+and link it to automerger plugin directory:
+
+```
+  cd automerger && ln -s ../bucklets .
+```
+
+Add link to the .buckversion file:
+
+```
+  cd automerger && ln -s bucklets/buckversion .buckversion
+```
+
+Add link to the .watchmanconfig file:
+```
+  cd automerger && ln -s bucklets/watchmanconfig .watchmanconfig
+```
+
+To build the plugin, issue the following command:
+
+
+```
+  buck build plugin
+```
+
+The output is created in
+
+```
+  buck-out/gen/automerger.jar
+```
+
+### Build in Gerrit tree
+
+Clone or link this plugin to the plugins directory of Gerrit's source
+tree, and issue the command:
+
+```
+  buck build plugins/automerger
+```
+
+The output is created in
+
+```
+  buck-out/gen/plugins/automerger/automerger.jar
+```
+
+This project can be imported into the Eclipse IDE:
+
+```
+  ./tools/eclipse/project.py
+```
+
+How to build the Gerrit Plugin API is described in the [Gerrit
+documentation](../../../Documentation/dev-buck.html#_extension_and_plugin_api_jar_files).
diff --git a/src/main/resources/Documentation/rest-api-automerge-change.md b/src/main/resources/Documentation/rest-api-automerge-change.md
new file mode 100644
index 0000000..f2fe9cc
--- /dev/null
+++ b/src/main/resources/Documentation/rest-api-automerge-change.md
@@ -0,0 +1,37 @@
+@PLUGIN@ automerge-change
+=============================
+
+NAME
+----
+automerge-change - Automerge a change downstream
+
+SYNOPSIS
+--------
+>     POST /projects/{project-name}/@PLUGIN@~automerge-change
+
+DESCRIPTION
+-----------
+Returns an HTTP 204 if successful.
+
+OPTIONS
+-------
+--branch_map
+> A map of downstream branches to their merge value (false means it is skipped)
+
+REQUEST
+-----------
+```
+  POST /projects/{project-name}/@PLUGIN@~automerge-change HTTP/1.0
+  Content-Type application/json;charset=UTF-8
+
+  {
+    "master": true,
+    "branch_two": false
+  }
+```
+
+RESPONSE
+-----------
+```
+  HTTP/1.1 204 No Content
+```
diff --git a/src/main/resources/Documentation/rest-api-config-downstream.md b/src/main/resources/Documentation/rest-api-config-downstream.md
new file mode 100644
index 0000000..45be612
--- /dev/null
+++ b/src/main/resources/Documentation/rest-api-config-downstream.md
@@ -0,0 +1,45 @@
+@PLUGIN@ config-downstream
+=============================
+
+NAME
+----
+config-downstream - Get the downstream config map
+
+SYNOPSIS
+--------
+>     POST /projects/{project-name}/@PLUGIN@~config-downstream
+
+DESCRIPTION
+-----------
+Returns a map of branches that are one hop downstream to whether or not it
+should be skipped by default.
+
+OPTIONS
+-------
+
+--subject
+> The subject of the current change
+
+REQUEST
+-----------
+```
+  POST /projects/{project-name}/@PLUGIN@~config-downstream HTTP/1.0
+  Content-Type application/json;charset=UTF-8
+
+  {
+    "subject": "DO NOT MERGE i am a test subject"
+  }
+```
+
+RESPONSE
+-----------
+```
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+  )]}'
+  {
+    "master": true,
+    "branch_two": false
+  }
+```
diff --git a/src/main/resources/config/config_keys.yaml b/src/main/resources/config/config_keys.yaml
new file mode 100644
index 0000000..6008124
--- /dev/null
+++ b/src/main/resources/config/config_keys.yaml
@@ -0,0 +1,14 @@
+config_project: tools/automerger
+config_project_branch: master
+config_filename: config.yaml
+global_keys:
+- always_blank_merge
+- blank_merge
+- manifest
+config_option_keys:
+- manifest
+- merge_all
+- merge_manifest
+- set_projects
+- ignore_projects
+- add_projects
\ No newline at end of file
diff --git a/src/main/resources/static/automerger.js b/src/main/resources/static/automerger.js
new file mode 100644
index 0000000..f6a3b5b
--- /dev/null
+++ b/src/main/resources/static/automerger.js
@@ -0,0 +1,93 @@
+// 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.
+
+var currentChange;
+var downstreamConfigMap;
+Gerrit.install(function(self) {
+
+    function onAutomergeChange(c) {
+        addCheckboxes(c, downstreamConfigMap);
+    }
+
+    function addCheckboxes(c, downstreamConfigMap) {
+        var branchToCheckbox = {};
+        var downstreamConfigBranches = Object.keys(downstreamConfigMap);
+        // Initialize checkboxes for each downstream branch
+        downstreamConfigBranches.forEach(function(branch) {
+            var checkbox = c.checkbox();
+            if (downstreamConfigMap[branch])
+                checkbox.checked = true;
+            branchToCheckbox[branch] = c.label(checkbox, branch);
+        });
+
+        //Add checkboxes to box for each downstream branch
+        var checkboxes = [];
+        Object.keys(branchToCheckbox).forEach(function(branch) {
+            checkboxes.push(branchToCheckbox[branch])
+            checkboxes.push(c.br());
+        });
+        // Create actual merge button
+        var b = createMergeButton(c, branchToCheckbox);
+        var popupElements = checkboxes.concat(b);
+        c.popup(c.div.apply(this, popupElements));
+        return branchToCheckbox;
+    }
+
+    function createMergeButton(c, branchToCheckbox) {
+        return c.button('Merge', {onclick: function(){
+            var branchMap = {};
+            Object.keys(branchToCheckbox).forEach(function(key){
+                branchMap[key] = branchToCheckbox[key].firstChild.checked;
+            });
+            // gerrit converts to camelcase on the java end
+            c.call({'branch_map': branchMap},
+                function(r){ Gerrit.refresh(); });
+        }});
+    }
+
+    function styleRelatedChanges() {
+        document.querySelectorAll('[data-branch]').forEach(function(relChange) {
+            var relatedBranch = relChange.dataset.branch;
+            if (relatedBranch == currentChange.branch) {
+                relChange.style.fontWeight = 'bold';
+            } else {
+                relChange.style.fontWeight = '';
+            }
+            if (relChange.innerText.includes('[skipped')) {
+                relChange.parentNode.style.backgroundColor = 'lightGray';
+            }
+        })
+    }
+
+    function getDownstreamConfigMap() {
+        var changeId = currentChange.id;
+        var revisionId = currentChange.current_revision;
+        var url = `/changes/${changeId}/revisions/${revisionId}` +
+                   `/automerger~config-downstream`;
+        Gerrit.post(
+            url, {'subject': currentChange.subject},
+            function(resp) {
+                downstreamConfigMap = resp;
+                styleRelatedChanges();
+            });
+    }
+
+    function onShowChange(e) {
+        currentChange = e;
+        getDownstreamConfigMap();
+    }
+
+    self.onAction('revision', 'automerge-change', onAutomergeChange);
+    Gerrit.on('showchange', onShowChange);
+});
\ No newline at end of file
diff --git a/src/test/java/com/googlesource/gerrit/plugins/automerger/ConfigLoaderTest.java b/src/test/java/com/googlesource/gerrit/plugins/automerger/ConfigLoaderTest.java
new file mode 100644
index 0000000..92c9391
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/automerger/ConfigLoaderTest.java
@@ -0,0 +1,151 @@
+// 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 com.google.common.base.Charsets;
+import com.google.common.io.CharStreams;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.HashSet;
+import java.util.Set;
+
+import static com.google.common.truth.Truth.assertThat;
+
+public class ConfigLoaderTest {
+  protected GerritApi gApiMock;
+  private ConfigLoader configLoader;
+  private String configString;
+  private String manifestString;
+  private String firstDownstreamManifestString;
+  private String secondDownstreamManifestString;
+
+  @Before
+  public void setUp() throws Exception {
+    gApiMock = Mockito.mock(GerritApi.class, Mockito.RETURNS_DEEP_STUBS);
+    mockFile("config.yaml", "tools/automerger", "master", "config.yaml");
+    mockFile("default.xml", "platform/manifest", "master", "default.xml");
+    mockFile("ds_one.xml", "platform/manifest", "ds_one", "default.xml");
+    mockFile("ds_two.xml", "platform/manifest", "ds_two", "default.xml");
+  }
+
+  private void mockFile(String resourceName, String projectName, String branchName, String filename)
+      throws Exception {
+    try (InputStream in = getClass().getResourceAsStream(resourceName)) {
+      String resourceString = CharStreams.toString(new InputStreamReader(in, Charsets.UTF_8));
+      Mockito.when(
+              gApiMock.projects().name(projectName).branch(branchName).file(filename).asString())
+          .thenReturn(resourceString);
+    }
+  }
+
+  private void loadConfig() throws Exception {
+    configLoader = new ConfigLoader(gApiMock);
+  }
+
+  @Test
+  public void getProjectsInScopeTest_addProjects() throws Exception {
+    loadConfig();
+    Set<String> expectedProjects = new HashSet<String>();
+    expectedProjects.add("platform/whee");
+    expectedProjects.add("platform/added/project");
+    assertThat(configLoader.getProjectsInScope("master", "ds_one")).isEqualTo(expectedProjects);
+  }
+
+  @Test
+  public void getProjectsInScopeTest_setProjects() throws Exception {
+    loadConfig();
+    Set<String> otherExpectedProjects = new HashSet<String>();
+    otherExpectedProjects.add("platform/some/project");
+    otherExpectedProjects.add("platform/other/project");
+    assertThat(configLoader.getProjectsInScope("master", "ds_two"))
+        .isEqualTo(otherExpectedProjects);
+  }
+
+  @Test
+  public void isSkipMergeTest_noSkip() throws Exception {
+    loadConfig();
+    assertThat(configLoader.isSkipMerge("ds_two", "ds_three", "bla")).isFalse();
+  }
+
+  @Test
+  public void isSkipMergeTest_blankMerge() throws Exception {
+    loadConfig();
+    assertThat(configLoader.isSkipMerge("ds_two", "ds_three", "test test \n \n DO NOT MERGE lala"))
+        .isTrue();
+  }
+
+  @Test
+  public void isSkipMergeTest_blankMergeWithMergeAll() throws Exception {
+    loadConfig();
+    assertThat(configLoader.isSkipMerge("master", "ds_two", "test test \n \n DO NOT MERGE"))
+        .isFalse();
+  }
+
+  @Test
+  public void isSkipMergeTest_alwaysBlankMerge() throws Exception {
+    loadConfig();
+    assertThat(
+            configLoader.isSkipMerge("master", "ds_one", "test test \n \n DO NOT MERGE ANYWHERE"))
+        .isTrue();
+  }
+
+  @Test
+  public void downstreamBranchesTest() throws Exception {
+    loadConfig();
+    Set<String> expectedBranches = new HashSet<String>();
+    expectedBranches.add("ds_two");
+    assertThat(configLoader.getDownstreamBranches("master", "platform/some/project"))
+        .isEqualTo(expectedBranches);
+  }
+
+  @Test
+  public void downstreamBranchesTest_nonexistentBranch() throws Exception {
+    loadConfig();
+    Set<String> expectedBranches = new HashSet<String>();
+    assertThat(configLoader.getDownstreamBranches("idontexist", "platform/some/project"))
+        .isEqualTo(expectedBranches);
+  }
+
+  @Test(expected = IOException.class)
+  public void downstreamBranchesTest_IOException() throws Exception {
+    Mockito.when(
+            gApiMock
+                .projects()
+                .name("platform/manifest")
+                .branch("master")
+                .file("default.xml")
+                .asString())
+        .thenThrow(new IOException("!"));
+    loadConfig();
+    Set<String> expectedBranches = new HashSet<String>();
+
+    configLoader.getDownstreamBranches("master", "platform/some/project");
+  }
+
+  @Test(expected = RestApiException.class)
+  public void downstreamBranchesTest_restApiException() throws Exception {
+    Mockito.when(gApiMock.projects().name("platform/manifest").branch("master"))
+        .thenThrow(new RestApiException("!"));
+    loadConfig();
+    configLoader.getDownstreamBranches("master", "platform/some/project");
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorTest.java
new file mode 100644
index 0000000..a9ee6f2
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/automerger/DownstreamCreatorTest.java
@@ -0,0 +1,251 @@
+// 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 com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.common.collect.ImmutableList;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.google.common.truth.Truth.assertThat;
+
+public class DownstreamCreatorTest {
+  private final String changeId = "testid";
+  private final String changeProject = "testproject";
+  private final String changeBranch = "testbranch";
+  private final String changeTopic = "testtopic";
+  private final String changeSubject = "testmessage";
+  private GerritApi gApiMock;
+  private DownstreamCreator ds;
+  private ConfigLoader configMock;
+
+  @Before
+  public void setUp() throws Exception {
+    gApiMock = Mockito.mock(GerritApi.class, Mockito.RETURNS_DEEP_STUBS);
+    configMock = Mockito.mock(ConfigLoader.class);
+    Mockito.when(configMock.getCodeReviewLabel()).thenReturn("Code-Review");
+    ds = new DownstreamCreator(gApiMock, configMock);
+  }
+
+  private List<ChangeInfo> mockChangeInfoList(String upstreamBranch) {
+    return ImmutableList.of(
+        mockChangeInfo(upstreamBranch, 1),
+        mockChangeInfo("testwhee", 2),
+        mockChangeInfo(upstreamBranch, 3));
+  }
+
+  private ChangeInfo mockChangeInfo(String upstreamRevision, int number) {
+    CommitInfo parent1 = Mockito.mock(CommitInfo.class);
+    parent1.commit = "infoparent" + number;
+    CommitInfo parent2 = Mockito.mock(CommitInfo.class);
+    parent2.commit = upstreamRevision;
+
+    ChangeInfo info = Mockito.mock(ChangeInfo.class);
+    info._number = number;
+    info.currentRevision = "info" + number;
+    info.revisions = Mockito.mock(Map.class);
+
+    RevisionInfo revisionInfoMock = Mockito.mock(RevisionInfo.class);
+    CommitInfo commit = Mockito.mock(CommitInfo.class);
+    commit.parents = ImmutableList.of(parent1, parent2);
+    revisionInfoMock.commit = commit;
+
+    Mockito.when(info.revisions.get(info.currentRevision)).thenReturn(revisionInfoMock);
+
+    return info;
+  }
+
+  @Test
+  public void testCreateDownstreamMerge() throws Exception {
+    String currentRevision = "testCurrentRevision";
+
+    ChangeInfo changeInfoMock = Mockito.mock(ChangeInfo.class);
+    changeInfoMock.id = "testnewchangeid";
+    ChangeApi changeApiMock = Mockito.mock(ChangeApi.class);
+    Mockito.when(changeApiMock.get(EnumSet.of(ListChangesOption.CURRENT_REVISION)))
+        .thenReturn(changeInfoMock);
+    Mockito.when(gApiMock.changes().create(Mockito.any(ChangeInput.class)))
+        .thenReturn(changeApiMock);
+    RevisionApi revisionApiMock = Mockito.mock(RevisionApi.class);
+    Mockito.when(gApiMock.changes().id(Mockito.anyString()).revision(Mockito.anyString()))
+        .thenReturn(revisionApiMock);
+
+    SingleDownstreamMergeInput dsMergeInput = new SingleDownstreamMergeInput();
+    dsMergeInput.currentRevision = currentRevision;
+    dsMergeInput.sourceId = changeId;
+    dsMergeInput.project = changeProject;
+    dsMergeInput.topic = changeTopic;
+    dsMergeInput.subject = changeSubject;
+    dsMergeInput.downstreamBranch = "testds";
+    dsMergeInput.doMerge = true;
+
+    ds.createSingleDownstreamMerge(dsMergeInput);
+
+    // Check ReviewInput is +2
+    ArgumentCaptor<ReviewInput> reviewInputArgument = ArgumentCaptor.forClass(ReviewInput.class);
+    Mockito.verify(revisionApiMock).review(reviewInputArgument.capture());
+    ReviewInput reviewInput = reviewInputArgument.getValue();
+    assertThat(reviewInput.labels.get("Code-Review")).isEqualTo(2);
+
+    // Check ChangeInput is the right project, branch, topic, subject
+    ArgumentCaptor<ChangeInput> changeInputCaptor = ArgumentCaptor.forClass(ChangeInput.class);
+    Mockito.verify(gApiMock.changes()).create(changeInputCaptor.capture());
+    ChangeInput changeInput = changeInputCaptor.getValue();
+    assertThat(changeProject).isEqualTo(changeInput.project);
+    assertThat("testds").isEqualTo(changeInput.branch);
+    assertThat(changeTopic).isEqualTo(changeInput.topic);
+    assertThat(changeInput.merge.source).isEqualTo(currentRevision);
+
+    String expectedSubject = changeSubject + " am: " + currentRevision.substring(0, 10);
+    assertThat(expectedSubject).isEqualTo(changeInput.subject);
+  }
+
+  @Test
+  public void testCreateDownstreamMerge_skipMerge() throws Exception {
+    String currentRevision = "testCurrentRevision";
+
+    ChangeInfo changeInfoMock = Mockito.mock(ChangeInfo.class);
+    changeInfoMock.id = "testnewchangeid";
+    ChangeApi changeApiMock = Mockito.mock(ChangeApi.class);
+    Mockito.when(changeApiMock.get(EnumSet.of(ListChangesOption.CURRENT_REVISION)))
+        .thenReturn(changeInfoMock);
+    Mockito.when(gApiMock.changes().create(Mockito.any(ChangeInput.class)))
+        .thenReturn(changeApiMock);
+    RevisionApi revisionApiMock = Mockito.mock(RevisionApi.class);
+    Mockito.when(gApiMock.changes().id(Mockito.anyString()).revision(Mockito.anyString()))
+        .thenReturn(revisionApiMock);
+
+    SingleDownstreamMergeInput dsMergeInput = new SingleDownstreamMergeInput();
+    dsMergeInput.currentRevision = currentRevision;
+    dsMergeInput.sourceId = changeId;
+    dsMergeInput.project = changeProject;
+    dsMergeInput.topic = changeTopic;
+    dsMergeInput.subject = changeSubject;
+    dsMergeInput.downstreamBranch = "testds";
+    dsMergeInput.doMerge = false;
+
+    ds.createSingleDownstreamMerge(dsMergeInput);
+
+    // Check ReviewInput is +2
+    ArgumentCaptor<ReviewInput> reviewInputArgument = ArgumentCaptor.forClass(ReviewInput.class);
+    Mockito.verify(revisionApiMock).review(reviewInputArgument.capture());
+    ReviewInput reviewInput = reviewInputArgument.getValue();
+    assertThat(reviewInput.labels.get("Code-Review")).isEqualTo(2);
+
+    // Check ChangeInput is the right project, branch, topic, subject
+    ArgumentCaptor<ChangeInput> changeInputCaptor = ArgumentCaptor.forClass(ChangeInput.class);
+    Mockito.verify(gApiMock.changes()).create(changeInputCaptor.capture());
+    ChangeInput changeInput = changeInputCaptor.getValue();
+    assertThat(changeProject).isEqualTo(changeInput.project);
+    assertThat("testds").isEqualTo(changeInput.branch);
+    assertThat(changeTopic).isEqualTo(changeInput.topic);
+    assertThat(changeInput.merge.source).isEqualTo(currentRevision);
+
+    // Check that it was actually skipped
+    String expectedSubject =
+        changeSubject + " skipped: " + currentRevision.substring(0, 10);
+    assertThat(changeInput.merge.strategy).isEqualTo("ours");
+    assertThat(expectedSubject).isEqualTo(changeInput.subject);
+  }
+
+  @Test
+  public void testCreateDownstreamMerges() throws Exception {
+    Map<String, Boolean> downstreamBranchMap = new HashMap<String, Boolean>();
+    downstreamBranchMap.put("testone", true);
+    downstreamBranchMap.put("testtwo", true);
+
+    MultipleDownstreamMergeInput mdsMergeInput = new MultipleDownstreamMergeInput();
+    mdsMergeInput.dsBranchMap = downstreamBranchMap;
+    mdsMergeInput.sourceId = changeId;
+    mdsMergeInput.project = changeProject;
+    mdsMergeInput.topic = changeTopic;
+    mdsMergeInput.subject = changeSubject;
+    mdsMergeInput.obsoleteRevision = null;
+    mdsMergeInput.currentRevision = "testCurrent";
+
+    ds.createDownstreamMerges(mdsMergeInput);
+
+    ArgumentCaptor<ChangeInput> changeInputCaptor = ArgumentCaptor.forClass(ChangeInput.class);
+    Mockito.verify(gApiMock.changes(), Mockito.times(2)).create(changeInputCaptor.capture());
+    List<ChangeInput> capturedChangeInputs = changeInputCaptor.getAllValues();
+    assertThat(capturedChangeInputs.get(0).branch).isEqualTo("testone");
+    assertThat(capturedChangeInputs.get(1).branch).isEqualTo("testtwo");
+  }
+
+  @Test
+  public void testCreateDownstreamMerges_withPreviousRevisions() throws Exception {
+    Map<String, Boolean> downstreamBranchMap = new HashMap<String, Boolean>();
+    downstreamBranchMap.put("testone", true);
+    downstreamBranchMap.put("testtwo", true);
+
+    List<ChangeInfo> changeInfoList = mockChangeInfoList("testup");
+    Mockito.when(
+            gApiMock
+                .changes()
+                .query(Mockito.anyString())
+                .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
+                .get())
+        .thenReturn(changeInfoList);
+
+    MultipleDownstreamMergeInput mdsMergeInput = new MultipleDownstreamMergeInput();
+    mdsMergeInput.dsBranchMap = downstreamBranchMap;
+    mdsMergeInput.sourceId = changeId;
+    mdsMergeInput.project = changeProject;
+    mdsMergeInput.topic = changeTopic;
+    mdsMergeInput.subject = changeSubject;
+    mdsMergeInput.obsoleteRevision = "testup";
+    mdsMergeInput.currentRevision = "testCurrent";
+
+    ds.createDownstreamMerges(mdsMergeInput);
+
+    // Check that previous revisions were updated
+    Mockito.verify(gApiMock.changes().id(Mockito.anyInt()), Mockito.times(2))
+        .createMergePatchSet(Mockito.any(MergePatchSetInput.class));
+  }
+
+  @Test
+  public void testGetExistingMergesOnBranch() throws Exception {
+    List<ChangeInfo> changeInfoList = mockChangeInfoList("testup");
+    Mockito.when(
+            gApiMock
+                .changes()
+                .query(Mockito.anyString())
+                .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_COMMIT)
+                .get())
+        .thenReturn(changeInfoList);
+
+    List<Integer> downstreamChangeNumbers =
+        ds.getExistingMergesOnBranch("testup", "testtopic", "testdown");
+    assertThat(downstreamChangeNumbers).containsExactly(1, 3).inOrder();
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/automerger/ManifestReaderTest.java b/src/test/java/com/googlesource/gerrit/plugins/automerger/ManifestReaderTest.java
new file mode 100644
index 0000000..bd7b79e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/automerger/ManifestReaderTest.java
@@ -0,0 +1,56 @@
+// 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 com.google.common.base.Charsets;
+import com.google.common.io.CharStreams;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.HashSet;
+import java.util.Set;
+
+import static com.google.common.truth.Truth.assertThat;
+
+public class ManifestReaderTest {
+  private ManifestReader manifestReader;
+  private String manifestString;
+
+  @Before
+  public void setUp() throws Exception {
+    try (InputStream in = getClass().getResourceAsStream("default.xml")) {
+      manifestString = CharStreams.toString(new InputStreamReader(in, Charsets.UTF_8));
+    }
+    manifestReader = new ManifestReader("master", manifestString);
+  }
+
+  @Test
+  public void basicParseTest() throws Exception {
+    Set<String> expectedSet = new HashSet<String>();
+    expectedSet.add("platform/whee");
+    expectedSet.add("whoo");
+    assertThat(manifestReader.getProjects()).isEqualTo(expectedSet);
+  }
+
+  @Test
+  public void branchDifferentFromDefaultRevisionTest() throws Exception {
+    ManifestReader aospManifestReader = new ManifestReader("mirror-aosp-master", manifestString);
+    Set<String> expectedSet = new HashSet<String>();
+    expectedSet.add("platform/whaa");
+    assertThat(aospManifestReader.getProjects()).isEqualTo(expectedSet);
+  }
+}
diff --git a/src/test/resources/com/googlesource/gerrit/plugins/automerger/config.yaml b/src/test/resources/com/googlesource/gerrit/plugins/automerger/config.yaml
new file mode 100644
index 0000000..271d875
--- /dev/null
+++ b/src/test/resources/com/googlesource/gerrit/plugins/automerger/config.yaml
@@ -0,0 +1,31 @@
+branches:
+  master:
+    ds_one:
+      add_projects:
+      - platform/added/project
+      ignore_projects:
+      - whoo
+    ds_two:
+      merge_all: true
+      add_projects:
+      - platform/added/project
+      ignore_projects:
+      - whoo
+      set_projects:
+      - platform/some/project
+      - platform/other/project
+  ds_two:
+    ds_three:
+      set_projects:
+      - platform/some/project
+global:
+  always_blank_merge:
+  - .*Import translations\.\sDO NOT MERGE.*
+  - .*DO NOT MERGE ANYWHERE.*
+  blank_merge:
+  - .*DO NOT MERGE.*
+  manifest:
+    file: default.xml
+    project: platform/manifest
+  ignore_projects:
+  - platform/ignore/me
diff --git a/src/test/resources/com/googlesource/gerrit/plugins/automerger/default.xml b/src/test/resources/com/googlesource/gerrit/plugins/automerger/default.xml
new file mode 100644
index 0000000..cf69b9b
--- /dev/null
+++ b/src/test/resources/com/googlesource/gerrit/plugins/automerger/default.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<manifest>
+  <default revision="master" />
+  <project path="whee" name="platform/whee" groups="pdk" />
+  <project path="whoo" name="whoo" groups="pdk" />
+  <project path="whaa" name="platform/whaa" groups="pdk" revision="mirror-aosp-master" />
+</manifest>
diff --git a/src/test/resources/com/googlesource/gerrit/plugins/automerger/ds_one.xml b/src/test/resources/com/googlesource/gerrit/plugins/automerger/ds_one.xml
new file mode 100644
index 0000000..5040efd
--- /dev/null
+++ b/src/test/resources/com/googlesource/gerrit/plugins/automerger/ds_one.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<manifest>
+  <default revision="ds_one" />
+  <project path="whee" name="platform/whee" groups="pdk" />
+  <project path="whoo" name="whoo" groups="pdk" />
+  <project path="whaa" name="platform/whaa" groups="pdk" revision="mirror-aosp-master" />
+</manifest>
diff --git a/src/test/resources/com/googlesource/gerrit/plugins/automerger/ds_two.xml b/src/test/resources/com/googlesource/gerrit/plugins/automerger/ds_two.xml
new file mode 100644
index 0000000..cc48734
--- /dev/null
+++ b/src/test/resources/com/googlesource/gerrit/plugins/automerger/ds_two.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<manifest>
+  <default revision="ds_two" />
+  <project path="whee" name="platform/whee" groups="pdk" />
+  <project path="whuu" name="whuu" groups="pdk" />
+  <project path="whaa" name="platform/whaa" groups="pdk" revision="mirror-aosp-master" />
+</manifest>