Task Plugin
Change-Id: I233cbf915b1c00e84b92ed34d9d3220803dc8ff5
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f3f0d6b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/target
+/.classpath
+/.project
+/.settings/org.maven.ide.eclipse.prefs
+/.settings/org.eclipse.m2e.core.prefs
+/.settings/org.eclipse.core.resources.prefs
+/.settings/org.eclipse.jdt.core.prefs
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/pom.xml b/pom.xml
new file mode 100644
index 0000000..a5f6065
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <groupId>com.googlesource.gerrit.plugins.task</groupId>
+ <artifactId>task</artifactId>
+ <packaging>jar</packaging>
+ <version>2.14-SNAPSHOT</version>
+ <name>task</name>
+
+ <properties>
+ <Gerrit-ApiType>plugin</Gerrit-ApiType>
+ <Gerrit-ApiVersion>${project.version}</Gerrit-ApiVersion>
+ </properties>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <version>2.4</version>
+ <configuration>
+ <archive>
+ <manifestEntries>
+ <Gerrit-Module>com.googlesource.gerrit.plugins.task.Modules$Module</Gerrit-Module>
+ <Gerrit-HttpModule>com.googlesource.gerrit.plugins.task.Modules$HttpModule</Gerrit-HttpModule>
+ <Gerrit-SshModule>com.googlesource.gerrit.plugins.task.Modules$SshModule</Gerrit-SshModule>
+ <Implementation-Vendor>Gerrit Code Review</Implementation-Vendor>
+ <Implementation-URL>http://code.google.com/p/gerrit/</Implementation-URL>
+
+ <Implementation-Title>${Gerrit-ApiType} ${project.artifactId}</Implementation-Title>
+ <Implementation-Version>${project.version}</Implementation-Version>
+
+ <Gerrit-ApiType>${Gerrit-ApiType}</Gerrit-ApiType>
+ <Gerrit-ApiVersion>${Gerrit-ApiVersion}</Gerrit-ApiVersion>
+ </manifestEntries>
+ </archive>
+ </configuration>
+ </plugin>
+
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <version>2.3.2</version>
+ <configuration>
+ <source>1.7</source>
+ <target>1.7</target>
+ <encoding>UTF-8</encoding>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.google.gerrit</groupId>
+ <artifactId>gerrit-${Gerrit-ApiType}-api</artifactId>
+ <version>${Gerrit-ApiVersion}</version>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <repositories>
+ <repository>
+ <id>gerrit-api-repository</id>
+ <url>https://gerrit-api.commondatastorage.googleapis.com/snapshot/</url>
+ </repository>
+ </repositories>
+</project>
diff --git a/src/main/java/com/google/gerrit/common/Container.java b/src/main/java/com/google/gerrit/common/Container.java
new file mode 100644
index 0000000..680be65
--- /dev/null
+++ b/src/main/java/com/google/gerrit/common/Container.java
@@ -0,0 +1,59 @@
+// 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.google.gerrit.common;
+
+import java.lang.IllegalAccessException;
+import java.lang.reflect.Field;
+import java.util.Objects;
+import java.util.List;
+import java.util.ArrayList;
+
+/* A data container, all fields considered in equals and hash */
+public class Container {
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ try {
+ for (Field field : getClass().getDeclaredFields()) {
+ field.setAccessible(true);
+ if (!Objects.deepEquals(field.get(this), field.get(o))) {
+ return false;
+ }
+ }
+ } catch (IllegalArgumentException | IllegalAccessException e) {
+ throw new RuntimeException();
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ List values = new ArrayList();
+ try {
+ for (Field field : getClass().getDeclaredFields()) {
+ field.setAccessible(true);
+ values.add(field.get(this));
+ }
+ } catch (IllegalArgumentException | IllegalAccessException e) {
+ }
+ return Objects.hash(values);
+ }
+}
diff --git a/src/main/java/com/google/gerrit/server/util/Git.java b/src/main/java/com/google/gerrit/server/util/Git.java
new file mode 100644
index 0000000..400ac4d
--- /dev/null
+++ b/src/main/java/com/google/gerrit/server/util/Git.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class Git {
+ public final GitRepositoryManager repos;
+
+ @Inject
+ public Git(GitRepositoryManager repos) {
+ this.repos = repos;
+ }
+
+ public ObjectId getObjectId(Branch.NameKey branch) throws IOException,
+ NoSuchRefException, RepositoryNotFoundException {
+ Repository repo = repos.openRepository(branch.getParentKey());
+ try {
+ return repo.getRef(branch.get()).getObjectId();
+ } finally {
+ repo.close();
+ }
+ }
+
+ public Map<Branch.NameKey, ObjectId> getObjectIdsByBranch(
+ Iterable<Branch.NameKey> branches) throws IOException,
+ NoSuchRefException, RepositoryNotFoundException {
+ Map<Branch.NameKey, ObjectId> idsByBranch =
+ new HashMap<Branch.NameKey, ObjectId>();
+ for (Branch.NameKey branch : branches) {
+ idsByBranch.put(branch, getObjectId(branch));
+ }
+ return idsByBranch;
+ }
+}
diff --git a/src/main/java/com/google/gerrit/server/util/RefUpdater.java b/src/main/java/com/google/gerrit/server/util/RefUpdater.java
new file mode 100644
index 0000000..d436bd4
--- /dev/null
+++ b/src/main/java/com/google/gerrit/server/util/RefUpdater.java
@@ -0,0 +1,179 @@
+// 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.google.gerrit.server.util;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.TagCache;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class RefUpdater {
+ private static final Logger log = LoggerFactory.getLogger(RefUpdater.class);
+
+ public class Args {
+ public final Branch.NameKey branch;
+ public ObjectId expectedOldObjectId;
+ public ObjectId newObjectId;
+ public boolean isForceUpdate;
+ public PersonIdent refLogIdent;
+
+ public Args(Branch.NameKey branch) {
+ this.branch = branch;
+ CurrentUser user = userProvider.get();
+ if (user instanceof IdentifiedUser) {
+ refLogIdent = ((IdentifiedUser) user).newRefLogIdent();
+ } else {
+ refLogIdent = gerrit;
+ }
+ }
+ }
+
+ private Provider<CurrentUser> userProvider;
+ private @GerritPersonIdent PersonIdent gerrit;
+ private GitRepositoryManager repoManager;
+ private GitReferenceUpdated gitRefUpdated;
+ private IdentifiedUser user;
+ private TagCache tagCache;
+
+ @Inject
+ RefUpdater(Provider<CurrentUser> userProvider,
+ @GerritPersonIdent PersonIdent gerrit, GitRepositoryManager repoManager,
+ TagCache tagCache, GitReferenceUpdated gitRefUpdated) {
+ this.userProvider = userProvider;
+ this.gerrit = gerrit;
+ this.repoManager = repoManager;
+ this.tagCache = tagCache;
+ this.gitRefUpdated = gitRefUpdated;
+ }
+
+ public void update(Branch.NameKey branch, ObjectId oldRefId,
+ ObjectId newRefId) throws IOException, RepositoryNotFoundException {
+ Args args = new Args(branch);
+ args.expectedOldObjectId = oldRefId;
+ args.newObjectId = newRefId;
+ update(args);
+ }
+
+ public void forceUpdate(Branch.NameKey branch, ObjectId newRefId) throws IOException, RepositoryNotFoundException {
+ Args args = new Args(branch);
+ args.newObjectId = newRefId;
+ args.isForceUpdate = true;
+ update(args);
+ }
+
+ public void delete(Branch.NameKey branch)
+ throws IOException, RepositoryNotFoundException {
+ Args args = new Args(branch);
+ args.newObjectId = ObjectId.zeroId();
+ args.isForceUpdate = true;
+ update(args);
+ }
+
+ public void update(Args args) throws IOException {
+ new Update(args).update();
+ }
+
+ private class Update {
+ Repository repo;
+ Args args;
+ RefUpdate update;
+ Branch.NameKey branch;
+ Project.NameKey project;
+ boolean delete;
+
+ Update(Args args) throws IOException {
+ this.args = args;
+ branch = args.branch;
+ project = branch.getParentKey();
+ delete = args.newObjectId.equals(ObjectId.zeroId());
+ }
+
+ void update() throws IOException {
+ repo = repoManager.openRepository(project);
+ try {
+ initUpdate();
+ handleResult(runUpdate());
+ } catch (IOException err) {
+ log.error("RefUpdate failed: branch not updated: " + branch.get(), err);
+ throw err;
+ } finally {
+ repo.close();
+ repo = null;
+ }
+ }
+
+ void initUpdate() throws IOException {
+ update = repo.updateRef(branch.get());
+ update.setExpectedOldObjectId(args.expectedOldObjectId);
+ update.setNewObjectId(args.newObjectId);
+ update.setRefLogIdent(args.refLogIdent);
+ update.setForceUpdate(args.isForceUpdate);
+ }
+
+ RefUpdate.Result runUpdate() throws IOException {
+ if (delete) {
+ return update.delete();
+ }
+ return update.update();
+ }
+
+ void handleResult(RefUpdate.Result result) throws IOException {
+ switch (result) {
+ case FORCED:
+ if (!delete && !args.isForceUpdate) {
+ throw new IOException(result.name());
+ }
+ case FAST_FORWARD:
+ case NEW:
+ case NO_CHANGE:
+ onUpdated(update, args);
+ break;
+ default:
+ throw new IOException(result.name());
+ }
+ }
+
+ void onUpdated(RefUpdate update, Args args) {
+ if (update.getResult() == RefUpdate.Result.FAST_FORWARD) {
+ tagCache.updateFastForward(project, update.getName(),
+ update.getOldObjectId(), args.newObjectId);
+ }
+
+ CurrentUser user = userProvider.get();
+ if (user instanceof IdentifiedUser) {
+ Account account = ((IdentifiedUser) user).getAccount();
+ gitRefUpdated.fire(project, update, account);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/AbstractVersionedMetaData.java b/src/main/java/com/googlesource/gerrit/plugins/task/AbstractVersionedMetaData.java
new file mode 100644
index 0000000..59f58eb
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/AbstractVersionedMetaData.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2013 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.task;
+
+import com.google.gerrit.server.git.VersionedMetaData;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
+
+/** Versioned Configuration file living in git */
+public class AbstractVersionedMetaData extends VersionedMetaData {
+ protected final Branch.NameKey branch;
+ protected final String fileName;
+ protected Config cfg;
+
+ public AbstractVersionedMetaData(Branch.NameKey branch, String fileName) {
+ this.branch = branch;
+ this.fileName = fileName;
+ }
+
+ @Override
+ protected String getRefName() {
+ return branch.get();
+ }
+
+ protected Branch.NameKey getBranch() {
+ return branch;
+ }
+
+ protected String getFileName() {
+ return fileName;
+ }
+
+ @Override
+ protected void onLoad() throws IOException, ConfigInvalidException {
+ cfg = readConfig(fileName);
+ }
+
+ public Config get() {
+ if (cfg == null) {
+ cfg = new Config();
+ }
+ return cfg;
+ }
+
+ @Override
+ protected boolean onSave(CommitBuilder commit) throws IOException,
+ ConfigInvalidException {
+ if (commit.getMessage() == null || "".equals(commit.getMessage())) {
+ commit.setMessage("Updated configuration\n");
+ }
+ saveConfig(fileName, cfg);
+ return true;
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
new file mode 100644
index 0000000..f1d4335
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.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.task;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.query.change.QueryChanges;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor.ChangeAttributeFactory;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+
+import org.kohsuke.args4j.Option;
+
+public class Modules {
+ public static class Module extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(ChangeAttributeFactory.class)
+ .annotatedWith(Exports.named("task"))
+ .to(TaskAttributeFactory.class);
+ }
+ }
+
+ public static class SshModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(DynamicBean.class)
+ .annotatedWith(Exports.named(Query.class))
+ .to(MyOptions.class);
+ }
+ }
+
+ public static class HttpModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(DynamicBean.class)
+ .annotatedWith(Exports.named(QueryChanges.class))
+ .to(MyOptions.class);
+ }
+ }
+
+ static class MyOptions implements DynamicBean {
+ @Option(name = "--applicable", usage = "Include applicable tasks")
+ boolean include = false;
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
new file mode 100644
index 0000000..b208f51
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
@@ -0,0 +1,372 @@
+// 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.task;
+
+import com.googlesource.gerrit.plugins.task.TaskConfig.External;
+import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor.ChangeAttributeFactory;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+public class TaskAttributeFactory implements ChangeAttributeFactory {
+ private static final Logger log =
+ LoggerFactory.getLogger(TaskAttributeFactory.class);
+
+ public enum Status {
+ INVALID, WAITING, READY, PASS, FAIL;
+ }
+
+ private static final String TASK_DIR = "task";
+
+ private ReviewDb db;
+ private AccountResolver accountResolver;
+ private AllUsersNameProvider allUsers;
+ private CurrentUser user;
+ private Provider<ChangeQueryBuilder> cqb;
+ private TaskConfigFactory taskFactory;
+
+ @Inject
+ public TaskAttributeFactory(ReviewDb db, AccountResolver accountResolver,
+ AllUsersNameProvider allUsers, AnonymousUser anonymousUser,
+ CurrentUser user, TaskConfigFactory taskFactory,
+ Provider<ChangeQueryBuilder> cqb) {
+ this.db = db;
+ this.accountResolver = accountResolver;
+ this.allUsers = allUsers;
+ this.user = user != null ? user : anonymousUser;
+ this.taskFactory = taskFactory;
+ this.cqb = cqb;
+ }
+
+ @Override
+ public PluginDefinedInfo create(ChangeData c, ChangeQueryProcessor qp,
+ String plugin) {
+ Modules.MyOptions options = (Modules.MyOptions) qp.getDynamicBean(plugin);
+ if (options != null && options.include) {
+ try {
+ return createWithExceptions(c);
+ } catch (OrmException e) {
+ log.error("Cannot load tasks for: " + c, e);
+ }
+ }
+ return null;
+ }
+
+ private PluginDefinedInfo createWithExceptions(ChangeData c) throws
+ OrmException {
+ TaskPluginAttribute a = new TaskPluginAttribute();
+ try {
+ LinkedList<Task> path = new LinkedList<Task>();
+ for (Task task : getRootTasks()) {
+ addApplicableTasks(a.roots, c, path, task);
+ }
+ } catch (ConfigInvalidException | IOException e) {
+ a.roots.add(invalid());
+ } catch (Exception e) {
+ log.error("Error processing tasks", e);
+ }
+
+ if (a.roots.isEmpty()) {
+ return null;
+ }
+ return a;
+ }
+
+ private void addApplicableTasks(List<TaskAttribute> tasks, ChangeData c,
+ LinkedList<Task> path, Task def) throws OrmException {
+ if (path.contains(def)) { // looping definition
+ tasks.add(invalid());
+ return;
+ }
+ path.addLast(def);
+ addApplicableTasksNoLoopCheck(tasks, c, path, def);
+ path.removeLast();
+ }
+
+ private void addApplicableTasksNoLoopCheck(List<TaskAttribute> tasks,
+ ChangeData c, LinkedList<Task> path, Task def) throws OrmException {
+ try {
+ if (match(c, def.applicable)) {
+ TaskAttribute task = new TaskAttribute(def.name);
+ if (def.inProgress != null) {
+ task.inProgress = match(c, def.inProgress);
+ }
+ task.subTasks = getSubTasks(c, path, def);
+ task.status = getStatus(c, def, task);
+ if (task.status != null) { // task still applies
+ task.hint = getHint(task.status, def);
+ tasks.add(task);
+ }
+ }
+ } catch(QueryParseException e) {
+ tasks.add(invalid()); // bad applicability query
+ }
+ }
+
+ private List<TaskAttribute> getSubTasks(ChangeData c,
+ LinkedList<Task> path, Task parent) throws OrmException {
+ List<Task> tasks = getSubTasks(parent);
+
+ List<TaskAttribute> subTasks = new ArrayList<TaskAttribute>();
+ for (String file : parent.subTasksFiles) {
+ try {
+ tasks.addAll(getTasks(parent.config.getBranch(),
+ resolveTaskFileName(file)));
+ } catch (ConfigInvalidException | IOException e) {
+ subTasks.add(invalid());
+ }
+ }
+ for (String external : parent.subTasksExternals) {
+ try {
+ External ext = parent.config.getExternal(external);
+ if (ext == null) {
+ subTasks.add(invalid());
+ } else {
+ tasks.addAll(getTasks(ext));
+ }
+ } catch (ConfigInvalidException | IOException e) {
+ subTasks.add(invalid());
+ }
+ }
+
+ for (Task task : tasks) {
+ addApplicableTasks(subTasks, c, path, task);
+ }
+
+ if (subTasks.isEmpty()) {
+ return null;
+ }
+ return subTasks;
+ }
+
+ private static TaskAttribute invalid() {
+ // For security reasons, do not expose the task name without knowing
+ // the visibility which is derived from its applicability.
+ TaskAttribute a = new TaskAttribute("UNKNOWN");
+ a.status = Status.INVALID;
+ return a;
+ }
+
+ private List<Task> getRootTasks()
+ throws ConfigInvalidException, IOException {
+ return taskFactory.getRootConfig().getRootTasks();
+ }
+
+ private List<Task> getSubTasks(Task parent) {
+ List<Task> tasks = new ArrayList<Task> ();
+ for (String name : parent.subTasks) {
+ tasks.add(parent.config.getTask(name));
+ }
+ return tasks;
+ }
+
+ private List<Task> getTasks(External external)
+ throws ConfigInvalidException, IOException, OrmException {
+ return getTasks(resolveUserBranch(external.user),
+ resolveTaskFileName(external.file));
+ }
+
+ private List<Task> getTasks(Branch.NameKey branch,
+ String file) throws ConfigInvalidException, IOException {
+ return taskFactory.getTaskConfig(branch, file).getTasks();
+ }
+
+ private String resolveTaskFileName(String file) throws ConfigInvalidException {
+ if (file == null) {
+ throw new ConfigInvalidException("External file not defined");
+ }
+ Path p = Paths.get(TASK_DIR, file);
+ if (!p.startsWith(TASK_DIR)) {
+ throw new ConfigInvalidException("task file not under " + TASK_DIR
+ + " directory: " + file);
+ }
+ return p.toString();
+ }
+
+ private Branch.NameKey resolveUserBranch(String user)
+ throws ConfigInvalidException, OrmException {
+ if (user == null) {
+ throw new ConfigInvalidException("External user not defined");
+ }
+ Account acct = accountResolver.find(db, user);
+ if (acct == null) {
+ throw new ConfigInvalidException("Cannot resolve user: " + user);
+ }
+ return new Branch.NameKey(allUsers.get(), RefNames.refsUsers(acct.getId()));
+ }
+
+ private Status getStatus(ChangeData c, Task task, TaskAttribute a)
+ throws OrmException {
+ try {
+ return getStatusWithExceptions(c, task, a);
+ } catch(QueryParseException e) {
+ return Status.INVALID;
+ }
+ }
+
+ private Status getStatusWithExceptions(ChangeData c, Task task,
+ TaskAttribute a) throws OrmException, QueryParseException {
+ if (isAllNull(task.pass, task.fail, a.subTasks)) {
+ // A leaf task has no defined subtasks.
+ boolean hasDefinedSubtasks = ! (task.subTasks.isEmpty()
+ && task.subTasksFiles.isEmpty() && task.subTasksExternals.isEmpty());
+ if (hasDefinedSubtasks) {
+ // Remove 'Grouping" tasks (tasks with subtasks but no PASS
+ // or FAIL criteria) from the output if none of their subtasks
+ // are applicable. i.e. grouping tasks only really apply if at
+ // least one of their subtasks apply.
+ return null;
+ }
+ // A leaf configuration without a PASS or FAIL criteria is a
+ // missconfiguration. Either someone forgot to add subtasks, or
+ // they forgot to add a PASS or FAIL criteria.
+ return Status.INVALID;
+ }
+
+ if (task.fail != null) {
+ if (match(c, task.fail)) {
+ // A FAIL definition is meant to be a hard blocking criteria
+ // (like a CodeReview -2). Thus, if hard blocked, it is
+ // irrelevant what the subtask states, or the PASS criteria are.
+ //
+ // It is also important that FAIL be useable to indicate that
+ // the task has actually executed. Thus subtask status,
+ // including a subtask FAIL should not appear as a FAIL on the
+ // parent task. This means that this is should be the only path
+ // to make a task have a FAIL status.
+ return Status.FAIL;
+ }
+ if (task.pass == null) {
+ // A task with a FAIL but no PASS criteria is a PASS-FAIL task
+ // (they are never "READY"). It didn't fail, so pass.
+ return Status.PASS;
+ }
+ }
+
+ if (a.subTasks != null && !isAll(a.subTasks, Status.PASS)) {
+ // It is possible for a subtask's PASS criteria to change while
+ // a parent task is executing, or even after the parent task
+ // completes. This can result in the parent PASS criteria being
+ // met while one or more of its subtasks no longer meets its PASS
+ // criteria (the subtask may now even meet a FAIL criteria). We
+ // never want the parent task to reflect a PASS criteria in these
+ // cases, thus we can safely return here without ever evaluating
+ // the task's PASS criteria.
+ return Status.WAITING;
+ }
+
+ if (task.pass != null && ! match(c, task.pass)) {
+ // Non-leaf tasks with no PASS criteria are supported in order
+ // to support "grouping tasks" (tasks with no function aside from
+ // organizing tasks). A task without a PASS criteria, cannot ever
+ // be expected to execute (how would you know if it has?), thus a
+ // pass criteria is required to possibly even be considered for
+ // READY.
+ return Status.READY;
+ }
+
+ return Status.PASS;
+ }
+
+ private String getHint(Status status, Task task) {
+ if (status == Status.READY) {
+ return task.readyHint;
+ } else if (status == Status.FAIL) {
+ return task.failHint;
+ }
+ return null;
+ }
+
+ private static boolean isAll(Iterable<TaskAttribute> tasks, Status state) {
+ for (TaskAttribute task : tasks) {
+ if (task.status != state) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean match(ChangeData c, String query) throws
+ OrmException, QueryParseException {
+ if (query == null || query.equalsIgnoreCase("true")) {
+ return true;
+ }
+ if (query == null || query.equalsIgnoreCase("true")) {
+ throw new QueryParseException(query);
+ }
+
+ c.notes(); // ChangeData bug, it fails to load notes for has:drafts
+ Predicate<ChangeData> pred = cqb.get().parse(query);
+ if (pred instanceof Matchable) {
+ return pred.asMatchable().match(c);
+ }
+ return false;
+ }
+
+ private static boolean isAllNull(Object... vals) {
+ for (Object val : vals) {
+ if (val != null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ static class TaskAttribute {
+ String name;
+ Boolean inProgress;
+ Status status;
+ String hint;
+ List<TaskAttribute> subTasks;
+
+ public TaskAttribute(String name) {
+ this.name = name;
+ }
+ }
+
+ static class TaskPluginAttribute extends PluginDefinedInfo {
+ public List roots = new ArrayList<TaskAttribute>();
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
new file mode 100644
index 0000000..1d4883d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
@@ -0,0 +1,149 @@
+// 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.task;
+
+import com.google.gerrit.common.Container;
+import com.google.gerrit.reviewdb.client.Branch;
+import org.eclipse.jgit.lib.Config;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/** Task Configuration file living in git */
+public class TaskConfig extends AbstractVersionedMetaData {
+ private class Section extends Container {
+ public TaskConfig config;
+
+ public Section() {
+ this.config = TaskConfig.this;
+ }
+ }
+
+ public class Task extends Section {
+ public String name;
+ public String applicable;
+ public String fail;
+ public String inProgress;
+ public String pass;
+ public String readyHint;
+ public String failHint;
+ public List<String> subTasks;
+ public List<String> subTasksExternals;
+ public List<String> subTasksFiles;
+
+ public Task(SubSection s) {
+ name = getString(s, KEY_NAME, s.subSection);
+ applicable = getString(s, KEY_APPLICABLE, null);
+ fail = getString(s, KEY_FAIL, null);
+ inProgress = getString(s, KEY_IN_PROGRESS, null);
+ pass = getString(s, KEY_PASS, null);
+ readyHint = getString(s, KEY_READY_HINT, null);
+ failHint = getString(s, KEY_FAIL_HINT, null);
+ subTasks = getStringList(s, KEY_SUBTASK);
+ subTasksExternals = getStringList(s, KEY_SUBTASKS_EXTERNAL);
+ subTasksFiles = getStringList(s, KEY_SUBTASKS_FILE);
+ }
+ }
+
+ public class External extends Section {
+ public String name;
+ public String file;
+ public String user;
+
+ public External(SubSection s) {
+ name = s.subSection;
+ file = getString(s, KEY_FILE, null);
+ user = getString(s, KEY_USER, null);
+ }
+ }
+
+ private static final String SECTION_EXTERNAL = "external";
+ private static final String SECTION_ROOT = "root";
+ private static final String SECTION_TASK = "task";
+ private static final String KEY_APPLICABLE = "applicable";
+ private static final String KEY_FAIL = "fail";
+ private static final String KEY_FILE = "file";
+ private static final String KEY_IN_PROGRESS = "in-progress";
+ private static final String KEY_NAME = "name";
+ private static final String KEY_PASS = "pass";
+ private static final String KEY_READY_HINT = "ready-hint";
+ private static final String KEY_FAIL_HINT = "fail-hint";
+ private static final String KEY_SUBTASK = "subtask";
+ private static final String KEY_SUBTASKS_EXTERNAL = "subtasks-external";
+ private static final String KEY_SUBTASKS_FILE = "subtasks-file";
+ private static final String KEY_USER = "user";
+
+ public TaskConfig(Branch.NameKey branch, String fileName) {
+ super(branch, fileName);
+ }
+
+ public List<Task> getRootTasks() {
+ return getTasks(SECTION_ROOT);
+ }
+
+ public List<Task> getTasks() {
+ return getTasks(SECTION_TASK);
+ }
+
+ private List<Task> getTasks(String type) {
+ List<Task> tasks = new ArrayList<Task>();
+ // No need to get a task with no name (what would we call it?)
+ for (String task : cfg.getSubsections(type)) {
+ tasks.add(new Task(new SubSection(type, task)));
+ }
+ return tasks;
+ }
+
+ public List<External> getExternals() {
+ List<External> externals = new ArrayList<External>();
+ // No need to get an external with no name (what would we call it?)
+ for (String external : cfg.getSubsections(SECTION_EXTERNAL)) {
+ externals.add(getExternal(external));
+ }
+ return externals;
+ }
+
+ public Task getTask(String name) {
+ return new Task(new SubSection(SECTION_TASK, name));
+ }
+
+ public External getExternal(String name) {
+ return getExternal(new SubSection(SECTION_EXTERNAL, name));
+ }
+
+ private External getExternal(SubSection s) {
+ return new External(s);
+ }
+
+ private String getString(SubSection s, String key, String def) {
+ String v = cfg.getString(s.section, s.subSection, key);
+ return v != null ? v : def;
+ }
+
+ private List<String> getStringList(SubSection s, String key) {
+ return Arrays.asList(cfg.getStringList(s.section, s.subSection, key));
+ }
+
+ private static class SubSection {
+ final String section;
+ final String subSection;
+
+ SubSection(String section, String subSection) {
+ this.section = section;
+ this.subSection = subSection;
+ }
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
new file mode 100644
index 0000000..a9cdbcc
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
@@ -0,0 +1,79 @@
+// 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.task;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+public class TaskConfigFactory {
+ private static final Logger log =
+ LoggerFactory.getLogger(TaskConfigFactory.class);
+
+ private static final String EXTENSION = ".config";
+ private static final String DEFAULT = "task" + EXTENSION;
+ private static final String LEGACY_DEFAULT = "tasks" + EXTENSION;
+
+ private final GitRepositoryManager gitMgr;
+ private final AllProjectsName allProjects;
+
+ @Inject
+ TaskConfigFactory(GitRepositoryManager gitMgr) {
+ // Injecting AllProjectsName doesn't work here
+ this.allProjects = new AllProjectsName("All-Projects");
+ this.gitMgr = gitMgr;
+ }
+
+ public TaskConfig getRootConfig() throws ConfigInvalidException, IOException {
+ TaskConfig cfg = getTaskConfig(getRootBranch(), DEFAULT);
+ if (cfg.getRootTasks().isEmpty()) {
+ return getTaskConfig(getRootBranch(), LEGACY_DEFAULT);
+ }
+ return cfg;
+ }
+
+ private Branch.NameKey getRootBranch() {
+ return new Branch.NameKey(allProjects, "refs/meta/config");
+ }
+
+ public TaskConfig getTaskConfig(Branch.NameKey branch, String fileName)
+ throws ConfigInvalidException, IOException {
+ TaskConfig cfg = new TaskConfig(branch, fileName);
+ Project.NameKey project = branch.getParentKey();
+ try {
+ Repository git = gitMgr.openRepository(project);
+ try {
+ cfg.load(git);
+ } finally {
+ git.close();
+ }
+ } catch (IOException e) {
+ log.warn("Failed to load " + fileName + " for " + project.get(), e);
+ throw e;
+ } catch (ConfigInvalidException e) {
+ throw e;
+ }
+ return cfg;
+ }
+}
diff --git a/src/main/resources/Documentation/task.md b/src/main/resources/Documentation/task.md
new file mode 100644
index 0000000..7db4c66
--- /dev/null
+++ b/src/main/resources/Documentation/task.md
@@ -0,0 +1,294 @@
+@PLUGIN@
+========
+
+The @PLUGIN@ plugin provides a mechanism to manage tasks which need to be
+performed on changes. The task plugin creates a common place where tasks can
+be defined, along with a common way to expose and query this information.
+Task definition includes defining which changes each task applies to, and how
+to determine the status for each task. Tasks are organized hierarchically.
+This hierarchy is considered for task applicability and status.
+
+An important use case of the task plugin is to have a common place for CI
+systems to define which changes they will operate on, and when they will do
+so. This makes it possible for independent and unrelated teams to setup
+entirely independent CI systems which operate on different sets of changes,
+all while exposing these applicability relations to Gerrit and other teams and
+users. This also makes it possible for work for a single change to be split
+across multiple cooperating CI systems so that assessments can be staged and
+gated upon various other tasks or assessments first completing (or passing).
+
+Exposing task applicability information helps users determine via Gerrit which,
+if any, system will "take care" of their changes. Users can thus figure this
+out without having the knowledge of "how to", or even "the ability to" query
+the configuration of external CI systems. This also makes it possible for
+conflicting systems to more easily be detected in cases when more than one
+system is mistakenly configured to be responsible for the same changes.
+
+Exposing task hierarchy information via Gerrit helps users understand the
+workflow that is expected of their changes. It helps them visualize task
+requirements and which tasks are expected to be completed before another task
+will even be attempted. It helps them understand how their changes are, or
+are not progressing through the outlined stages.
+
+Exposing task status information helps users and CI systems determine via
+Gerrit when it is appropriate for them to take action (to perform their task).
+It helps them identify blocking tasks. It helps them figure out what they
+can do, or perhaps who they need to talk to to ensure that their changes do
+make progress through all their hoops.
+
+Task definitions can be split up across multiple files/refs, and even
+across multiple projects. This splitting of task definitions allows the
+control of task definitions to be delegated to different entities. By
+aligning ref boundaries with controlling entities, the standard gerrit ref
+ACL mechanisms may be used to control who can define tasks on which changes.
+
+Task Status
+-----------
+Task status is used to indicate either the readiness of a task for execution
+if it has not yet completed execution, or the outcome of the task if it has
+completed execution. Tasks generally progress from `WAITING` to `READY` as
+their subtasks complete, and then from `READY` to `PASS` once the task itself
+completes.
+
+A task with a `WAITING` status is not yet ready to execute. A task in this
+state is blocked by its subtasks which are not yet in the `PASS` state.
+
+A task with a `READY` status is ready to be executed. All of its subtasks are
+in the `PASS` state.
+
+A task with a `PASS` status meets all the criteria for `READY`, and has
+executed and was successful.
+
+A task with a `FAIL` status has executed and was unsuccessful.
+
+A task with a `INVALID` status has an invalid definition.
+
+Tasks
+-----
+The applicability of each task is constrained to specific changes using an
+"applicable" query. Since tasks are defined hierarchically, the applicability
+of subtasks is inherently limited by the applicability of all tasks above them
+in the hierarchy. Tasks can either be root tasks, or subtasks. Tasks with no
+defined pass criteria and with defined subtasks are valid, but they are only
+applicable when at least one subtask is applicable.
+
+Tasks are defined in the `All-Projects` project, on the `refs/meta/config`
+branch, in a file named `task.config`. This file uses the gitconfig
+format to define root tasks and subtasks. The special "True" keyword
+may be used as any query definition to indicate an always matchign query.
+The following keys may be defined in any task section:
+
+`applicable`
+
+: This key defines a query that is used to determine whether a task is
+applicable to each change.
+
+Example:
+```
+ applicable = status:open
+```
+
+`fail`
+
+: This key defines a query that is used to determine whether a task has
+already executed and failed for each change.
+
+Example:
+```
+ fail = label:verified-1
+```
+
+`in-progress`
+
+: This key defines a query that is used to determine whether a task is
+currently in-progress or not. A CI system may use this to ensure that it
+only runs one verification instance for a specific change. Either a pass
+or fail key is mandatory for leaf tasks. A task with a fail criteria,
+but no pass criteria, will pass if it otherwise would be ready. Setting
+this to "True" is useful for defining blocking criteria that do not
+actually have a task to execute.
+
+Example:
+```
+ in-progress = label:patchset-lock,user=jenkins
+```
+
+`pass`
+
+: This key defines a query that is used to determine whether a task has
+already executed and passed for each change. Either a pass or fail
+key is mandatory for leaf tasks. Setting this to "True" is useful for
+defining informational tasks that are not really expected to execute.
+
+Example:
+```
+ pass = label:verified+1
+```
+
+Example:
+```
+ in-progress = label:patchset-lock,user=jenkins
+```
+
+`ready-hint`
+
+: This key defines a hint when a task is `READY` describing what
+accomplishing the tasks entails. This is meant to be a hint for humans
+and may be used as a tool-tip.
+
+Example:
+```
+ ready-hint = Needs to be verified by Jenkins
+```
+
+`fail-hint`
+
+: This key defines a hint when a task is in the `FAIL` state describing why
+the tasks is failing. This is meant to be a hint for humans
+and may be used as a tool-tip.
+
+Example:
+```
+ fail-hint = Blocked by a negative review score
+```
+
+`subtask`
+
+: This key lists the name of a subtask of the current task. This key may be
+used several times in a task section to define more than one subtask for a
+particular task.
+
+Example:
+
+```
+ subtask = "Code Review"
+ subtask = "License Approval"
+```
+
+`subtasks-external`
+
+: This key indicate a file containing subtasks of the current task. This
+key may be used several times in a task section to define more than one file
+containing subtasks for a particular task. The subtasks-external key points
+to an external file defined by external section. Note: all of the tasks in
+the referenced file will be included as sub tasks of the current task!
+
+Example:
+
+```
+ subtasks-external = my-external
+```
+
+`subtasks-file`
+
+: This key indicate a file containing subtasks of the current task. This
+key may be used several times in a task section to define more than one file
+containing subtasks for a particular task. The subtasks-file key points to
+a file under the top level task directory in the same project and ref as the
+current task file. Note: all of the tasks in the referenced file will be
+included as sub tasks of the current task!
+
+Example:
+
+```
+ subtasks-file = common.config # references the file named task/common.config
+```
+
+Root Tasks
+----------
+Root tasks typically define the "final verification" tasks for changes. Each
+root task likely defines a single CI system which is responsible for verifying
+and possibly submitting the changes which are managed by that CI system.
+Applicable queries for all root tasks should generally be defined in a non
+overlapping fashion.
+
+Root tasks are defined using "root" sections. A sample task.config which
+defines 3 non overlapping CI systems might look like this:
+
+```
+[root "Jenkins Build"]
+ applicable = status:open AND (project:a OR project:b) AND -branch:master
+ ...
+
+[root "Jenkins Build and Test"]
+ applicable = status:open AND (project:a OR project:b) AND branch:master
+ ...
+
+[root "Buildbot"]
+ applicable = status:open AND project:c
+ ...
+```
+
+Subtasks
+--------
+Subtasks define tasks that must pass before their parent task state is
+considered `READY` or `PASS`. Subtasks make it possible to define task
+execution dependencies and ordering. Subtasks typically define all the
+things that are required for change submission except for the final criteria
+that will be assessed by the final verification defined by the change's root
+task. This may include tasks that need to be executed by humans, such as
+approvals like `code-review`, along with automated tasks such as tests, or
+static analysis tool executions.
+
+Subtasks are defined using a "task" section. An example subtask definition:
+
+```
+[task "Code Review"]
+ pass = label:code-review+2
+ fail = label:code-review-2
+```
+
+External Entries
+----------------
+A name for external task files on other projects and branches may be given
+by defining an `external` section in a task file. This later allows this
+external name to then be referenced by other definitions. The following
+keys may be defined in an external section. External references are limited
+to files under the top level task directory.
+
+`file`
+
+: This key defines the name of the external task file under the
+task directory referenced.
+
+Example:
+
+```
+ file = common.config # references the file named task/common.config
+```
+
+`user`
+
+: This key defines the username of the user's ref in the `All-Users` project
+of the external file referenced.
+
+Example:
+
+```
+ user = first-user # references the sharded user ref refs/users/01/1000001
+```
+
+Change Query Output
+-------------------
+Changes which have tasks applicable to them will have a "task" section
+which will include applicable tasks for the change added to their output.
+
+```
+ $ ssh -x -p 29418 example.com gerrit query change:123
+ change I9fdfb1315610a8e3d5c48e4321193b7c265f30ae
+ ...
+ plugins:
+ name: task
+ roots:
+ name: Jenkins Build and Test
+ inProgress: false
+ status: READY
+ subTasks:
+ name: code review
+ status: PASS
+```
+
+Examples
+--------
+See [task_states](task_states.html) for a comprehensive list of examples
+of task configs and their states.
diff --git a/src/main/resources/Documentation/task_states.md b/src/main/resources/Documentation/task_states.md
new file mode 100644
index 0000000..826b0a1
--- /dev/null
+++ b/src/main/resources/Documentation/task_states.md
@@ -0,0 +1,464 @@
+@PLUGIN@ States
+===============
+
+Below are sample config files which illustrate many examples of how task
+states are affected by their own criteria and their subtasks' states.
+
+`task.config` file in project `All-Project` on ref `refs/meta/config`.
+
+```
+[root "Root PASS"]
+ applicable = has:draft
+ pass = True
+
+[root "Root FAIL"]
+ applicable = has:draft
+ fail = True
+ fail-hint = Change has a draft
+
+[root "Root straight PASS"]
+ applicable = has:draft
+ pass = has:draft
+
+[root "Root straight FAIL"]
+ applicable = has:draft
+ fail = has:draft
+ pass = has:draft
+
+[root "Root PASS-fail"]
+ applicable = has:draft
+ fail = NOT has:draft
+
+[root "Root pass-FAIL"]
+ applicable = has:draft
+ fail = has:draft
+
+[root "Root grouping PASS (subtask PASS)"]
+ applicable = has:draft
+ subtask = Subtask PASS
+
+[root "Root grouping WAITING (subtask READY)"]
+ applicable = has:draft
+ subtask = Subtask READY
+
+[root "Root grouping WAITING (subtask FAIL)"]
+ applicable = has:draft
+ subtask = Subtask FAIL
+
+[root "Root grouping NA (subtask NA)"]
+ applicable = has:draft
+ subtask = Subtask NA
+
+[root "Root READY (subtask PASS)"]
+ applicable = has:draft
+ pass = -has:draft
+ subtask = Subtask PASS
+ ready-hint = You must now run the ready task
+
+[root "Root WAITING (subtask READY)"]
+ applicable = has:draft
+ pass = has:draft
+ subtask = Subtask READY
+
+[root "Root WAITING (subtask FAIL)"]
+ applicable = has:draft
+ pass = has:draft
+ subtask = Subtask FAIL
+
+[root "Root IN PROGRESS"]
+ applicable = has:draft
+ in-progress = has:draft
+ pass = -has:draft
+
+[root "Root NOT IN PROGRESS"]
+ applicable = has:draft
+ in-progress = -has:draft
+ pass = -has:draft
+
+[root "Subtasks File"]
+ applicable = has:draft
+ subtasks-file = common.config
+
+[root "Subtasks File (Missing)"]
+ applicable = has:draft
+ subtasks-file = common.config
+ subtasks-file = missing
+
+[root "Subtasks External"]
+ applicable = has:draft
+ subtasks-external = user special
+
+[root "Subtasks External (Missing)"]
+ applicable = has:draft
+ subtasks-external = user special
+ subtasks-external = missing
+
+[root "Subtasks External (User Missing)"]
+ applicable = has:draft
+ subtasks-external = user special
+ subtasks-external = user missing
+
+[root "Subtasks External (File Missing)"]
+ applicable = has:draft
+ subtasks-external = user special
+ subtasks-external = file missing
+
+[root "INVALIDS"]
+ applicable = has:draft
+ subtasks-file = invalids.config
+
+[task "Subtask FAIL"]
+ applicable = has:draft
+ fail = has:draft
+ pass = has:draft
+
+[task "Subtask READY"]
+ applicable = has:draft
+ pass = -has:draft
+ subtask = Subtask PASS
+
+[task "Subtask PASS"]
+ applicable = has:draft
+ pass = has:draft
+
+[task "Subtask NA"]
+ applicable = NOT has:draft
+
+[external "user special"]
+ user = mfick
+ file = special.config
+
+[external "user missing"]
+ user = missing
+ file = special.config
+
+[external "file missing"]
+ user = mfick
+ file = missing
+```
+
+`task/common.config` file in project `All-Projects` on ref `refs/meta/config`.
+
+```
+[task "file task/common.config PASS"]
+ applicable = has:draft
+ pass = has:draft
+
+[task "file task/common.config FAIL"]
+ applicable = has:draft
+ fail = has:draft
+ pass = has:draft
+```
+
+`task/invalids.config` file in project `All-Projects` on ref `refs/meta/config`.
+
+```
+[task "No PASS criteria"]
+ applicable = has:draft
+
+[task "WAITING (subtask INVALID)"]
+ applicable = has:draft
+ pass = has:draft
+ subtask = Subtask INVALID
+
+[task "WAITING (subtask missing)"]
+ applicable = has:draft
+ pass = has:draft
+ subtask = MISSING # security bug: subtask name appears in output
+
+[task "Grouping WAITING (subtask INVALID)"]
+ applicable = has:draft
+ subtask = Subtask INVALID
+
+[task "Grouping WAITING (subtask missing)"]
+ applicable = has:draft
+ subtask = MISSING # security bug: subtask name appears in output
+
+[task "Subtask INVALID"]
+ applicable = has:draft
+
+```
+
+`task/special.config` file in project `All-Users` on ref `refs/users/01/1000001`.
+
+```
+[task "userfile task/special.config PASS"]
+ applicable = has:draft
+ pass = has:draft
+
+[task "userfile task/special.config FAIL"]
+ applicable = has:draft
+ fail = has:draft
+ pass = has:draft
+```
+
+The expeced output for the above task configs looks like:
+
+```
+ $ ssh -x -p 29418 review-example gerrit query has:draft \
+ --task--applicable --format json|head -1 |json_pp
+{
+ ...,
+ "plugins" : [
+ {
+ "roots" : [
+ {
+ "status" : "PASS",
+ "name" : "Root PASS"
+ },
+ {
+ "hint" : "Change has a draft",
+ "status" : "FAIL",
+ "name" : "Root FAIL"
+ },
+ {
+ "status" : "PASS",
+ "name" : "Root straight PASS"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "Root straight FAIL"
+ },
+ {
+ "status" : "PASS",
+ "name" : "Root PASS-fail"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "Root pass-FAIL"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "Subtask PASS"
+ }
+ ],
+ "status" : "PASS",
+ "name" : "Root grouping PASS (subtask PASS)"
+ },
+ {
+ "subTasks" : [
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "Subtask PASS"
+ }
+ ],
+ "status" : "READY",
+ "name" : "Subtask READY"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Root grouping WAITING (subtask READY)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "FAIL",
+ "name" : "Subtask FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Root grouping WAITING (subtask FAIL)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "Subtask PASS"
+ }
+ ],
+ "hint" : "You must now run the ready task",
+ "status" : "READY",
+ "name" : "Root READY (subtask PASS)"
+ },
+ {
+ "subTasks" : [
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "Subtask PASS"
+ }
+ ],
+ "status" : "READY",
+ "name" : "Subtask READY"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Root WAITING (subtask READY)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "FAIL",
+ "name" : "Subtask FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Root WAITING (subtask FAIL)"
+ },
+ {
+ "inProgress" : true,
+ "status" : "READY",
+ "name" : "Root IN PROGRESS"
+ },
+ {
+ "inProgress" : false,
+ "status" : "READY",
+ "name" : "Root NOT IN PROGRESS"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "file task/common.config PASS"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "file task/common.config FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Subtasks File"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "file task/common.config PASS"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "file task/common.config FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Subtasks File (Missing)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "userfile task/special.config PASS"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "userfile task/special.config FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Subtasks External"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "INVALID",
+ "name" : "UNKNOWN"
+ },
+ {
+ "status" : "PASS",
+ "name" : "userfile task/special.config PASS"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "userfile task/special.config FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Subtasks External (Missing)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "INVALID",
+ "name" : "UNKNOWN"
+ },
+ {
+ "status" : "PASS",
+ "name" : "userfile task/special.config PASS"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "userfile task/special.config FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Subtasks External (User Missing)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "PASS",
+ "name" : "userfile task/special.config PASS"
+ },
+ {
+ "status" : "FAIL",
+ "name" : "userfile task/special.config FAIL"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Subtasks External (File Missing)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "INVALID",
+ "name" : "No PASS criteria"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "INVALID",
+ "name" : "Subtask INVALID"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "WAITING (subtask INVALID)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "INVALID",
+ "name" : "MISSING"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "WAITING (subtask missing)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "INVALID",
+ "name" : "Subtask INVALID"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Grouping WAITING (subtask INVALID)"
+ },
+ {
+ "subTasks" : [
+ {
+ "status" : "INVALID",
+ "name" : "MISSING"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "Grouping WAITING (subtask missing)"
+ },
+ {
+ "status" : "INVALID",
+ "name" : "Subtask INVALID"
+ }
+ ],
+ "status" : "WAITING",
+ "name" : "INVALIDS"
+ }
+ ],
+ "name" : "task"
+ }
+ ],
+ ...
+```
diff --git a/test/check_task_statuses.sh b/test/check_task_statuses.sh
new file mode 100755
index 0000000..88b5042
--- /dev/null
+++ b/test/check_task_statuses.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+
+example() { # example_num
+ awk '/```/{Q++;E=(Q+1)/2};E=='"$1" < "$DOC_STATES" | grep -v '```'
+}
+
+setup_repo() { # repo remote ref
+ local repo=$1 remote=$2 ref=$3
+ git init "$repo"
+ (
+ cd "$repo"
+ git fetch "$remote" "$ref"
+ git checkout FETCH_HEAD
+ )
+}
+
+update_repo() { # repo remote ref
+ local repo=$1 remote=$2 ref=$3
+ (
+ cd "$repo"
+ git add .
+ git commit -m 'Testing task plugin'
+ git push "$remote" HEAD:"$ref"
+ )
+}
+
+query() { # query
+ ssh -x -p "$PORT" "$SERVER" gerrit query "$1" \
+ --format json --task--applicable| head -1 | json_pp
+}
+
+query_plugins() { query "$1" | awk '$0==" \"plugins\" : [",$0==" ],"' ; }
+
+MYDIR=$(dirname "$0")
+DOCS=$MYDIR/.././src/main/resources/Documentation
+OUT=$MYDIR/../target/tests
+
+ALL=$OUT/All-Projects
+ALL_TASKS=$ALL/task
+
+USERS=$OUT/All-Users
+USER_TASKS=$USERS/task
+
+DOC_STATES=$DOCS/task_states.md
+EXPECTED=$OUT/expected
+STATUSES=$OUT/statuses
+
+ROOT_CFG=$ALL/task.config
+COMMON_CFG=$ALL_TASKS/common.config
+INVALIDS_CFG=$ALL_TASKS/invalids.config
+USER_SPECIAL_CFG=$USER_TASKS/special.config
+
+# --- Args ----
+SERVER=$1
+[ -z "$SERVER" ] && { echo "You must specify a server" ; exit ; }
+
+PORT=29418
+REMOTE_ALL=ssh://$SERVER:$PORT/All-Projects
+REMOTE_USERS=ssh://$SERVER:$PORT/All-Users
+
+REF_ALL=refs/meta/config
+REF_USERS=refs/users/self
+
+
+
+mkdir -p "$OUT"
+setup_repo "$ALL" "$REMOTE_ALL" "$REF_ALL"
+setup_repo "$USERS" "$REMOTE_USERS" "$REF_USERS"
+
+mkdir -p "$ALL_TASKS" "$USER_TASKS"
+
+example 1 > "$ROOT_CFG"
+example 2 > "$COMMON_CFG"
+example 3 > "$INVALIDS_CFG"
+example 4 > "$USER_SPECIAL_CFG"
+
+update_repo "$ALL" "$REMOTE_ALL" "$REF_ALL"
+update_repo "$USERS" "$REMOTE_USERS" "$REF_USERS"
+
+
+example 5 |tail -n +5| awk 'NR>1{print P};{P=$0}' > "$EXPECTED"
+query_plugins has:draft > "$STATUSES"
+
+diff "$EXPECTED" "$STATUSES"