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>