Merge branch 'stable-3.0' into stable-3.1

Update Docker image to 3.1.12 matching plugin API for build.

* stable-3.0: (21 commits)
  fixup! test/docker: Use gerritcodereview docker image
  Add counts to Task headers
  Task UI: Add links to see full view of all tasks
  Add Bazel support for running docker tests
  test/docker: Use gerritcodereview docker image
  Revert "plugin:task Adds support for names-factory of type change"
  Remove Maven build
  Task UI: Add status icons
  TaskAttributeFactory: Consistently cache predicates
  task: Remove unthrown exception
  TaskAttributeFactory turn more Exceptions into INVALID tasks
  plugin:task Adds support for names-factory of type change
  Add support for tasks-factory and names-factory keywords
  task plugin tests: handle missing refs/users/self
  Task UI In Progress
  Refactor docker test setup
  Use a shorter docker test script name
  Support to run shell script tests in docker environment
  Upgrade bazlets to latest stable-3.0 to build with 3.0.15 API
  Upgrade bazlets to latest stable-2.16 to build with 2.16.26 API
  ...

Change-Id: I8ed747be43a330e5e49ca55c01a95b771d62bdfe
diff --git a/.gitignore b/.gitignore
index da5cb24..c13cf52 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@
 /package-lock.json
 /task.iml
 /node_modules/
+/test/docker/gerrit/artifacts
diff --git a/.zuul.yaml b/.zuul.yaml
new file mode 100644
index 0000000..e95b300
--- /dev/null
+++ b/.zuul.yaml
@@ -0,0 +1,10 @@
+- job:
+    name: plugins-task-build
+    parent: gerrit-plugin-build
+    pre-run:
+        tools/playbooks/install_docker.yaml
+
+- project:
+    check:
+      jobs:
+        - plugins-task-build
diff --git a/BUILD b/BUILD
index 5a2eb90..b2b57ae 100644
--- a/BUILD
+++ b/BUILD
@@ -6,14 +6,16 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:js.bzl", "polygerrit_plugin")
 
+plugin_name = "task"
+
 gerrit_plugin(
-    name = "task",
+    name = plugin_name,
     srcs = glob(["src/main/java/**/*.java"]),
     manifest_entries = [
-        "Gerrit-PluginName: task",
-        "Gerrit-ApiVersion: 3.0-SNAPSHOT",
+        "Gerrit-PluginName: " + plugin_name,
+        "Gerrit-ApiVersion: 3.1.12",
         "Implementation-Title: Task Plugin",
-        "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/task",
+        "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/" + plugin_name,
         "Gerrit-Module: com.googlesource.gerrit.plugins.task.Modules$Module",
     ],
     resource_jars = [":gr-task-plugin-static"],
@@ -40,3 +42,12 @@
     ]),
     app = "plugin.html",
 )
+
+sh_test(
+    name = "docker-tests",
+    size = "medium",
+    srcs = ["test/docker/run.sh"],
+    args = ["--task-plugin-jar", "$(location :task)"],
+    data = [plugin_name] + glob(["test/**"]),
+    local = True,
+)
diff --git a/gr-task-plugin/gr-task-plugin.html b/gr-task-plugin/gr-task-plugin.html
index 67c1319..c5c04ff 100644
--- a/gr-task-plugin/gr-task-plugin.html
+++ b/gr-task-plugin/gr-task-plugin.html
@@ -17,15 +17,50 @@
 <dom-module id="gr-task-plugin">
   <template>
       <style>
-        ul { padding-left: 30px; }
-        h3 { padding-left: 5px; }
+        ul { padding-left: 0.5em; }
+        h3 { padding-left: 0.1em; }
+        .cursor { cursor: pointer; }
+        #tasks_header {
+          align-items: center;
+          background-color: #fafafa;
+          border-top: 1px solid #ddd;
+          display: flex;
+          padding: 6px 1rem;
+        }
       </style>
 
       <div id="tasks" hidden$="[[!_tasks.length]]">
-        <h3>Tasks: (Needs + Blocked)</h3>
-        <ul>
-          <gr-task-plugin-tasks tasks="[[_tasks]]"></gr-task-plugin-tasks>
-        </ul>
+        <div id="tasks_header" style="display: flex;">
+          <iron-icon
+              icon="gr-icons:expand-less"
+              hidden$="[[!_expand_all]]"
+              on-tap="_switch_expand"
+              class="cursor"> </iron-icon>
+          <iron-icon
+              icon="gr-icons:expand-more"
+              hidden$="[[_expand_all]]"
+              on-tap="_switch_expand"
+              class="cursor"> </iron-icon>
+          <div style="display: flex; align-items: center; column-gap: 1em;">
+          <h3 on-tap="_switch_expand" class="cursor"> Tasks </h3>
+          <template is="dom-if" if="[[_expand_all]]">
+            <gr-button
+                on-tap="_show_all_tap"
+                disabled="[[_is_show_all(_show_all)]]"> Show All ([[_all_count]]) </gr-button>
+            <gr-button
+                on-tap="_needs_and_blocked_tap"
+                disabled="[[!_is_show_all(_show_all)]]">
+              Needs + Blocked ([[_ready_count]], [[_fail_count]]) </gr-button>
+          </template>
+        </div>
+        </div>
+        <div hidden$="[[!_expand_all]]">
+          <ul style="list-style-type:none;">
+            <gr-task-plugin-tasks
+                tasks="[[_tasks]]"
+                show_all$="[[_show_all]]"> </gr-task-plugin-tasks>
+          </ul>
+        </div>
       </div>
   </template>
   <script src="gr-task-plugin.js"></script>
@@ -34,10 +69,42 @@
 <dom-module id="gr-task-plugin-tasks">
   <template>
     <template is="dom-repeat" as="task" items="[[tasks]]">
-      <template is="dom-if" if="[[task.message]]">
-        <li>[[task.message]]</li>
+      <template is="dom-if" if="[[_can_show(show_all, task)]]">
+        <li>
+          <style>
+            /* Matching colors with core code. */
+            .green {
+              color: #9fcc6b;
+            }
+            .red {
+              color: #FFA62F;
+            }
+          </style>
+          <template is="dom-if" if="[[task.icon.id]]">
+            <gr-tooltip-content
+                has-tooltip
+                title="In Progress">
+                <iron-icon
+                  icon="gr-icons:hourglass"
+                  class="green"
+                  hidden$="[[!task.in_progress]]">
+                </iron-icon>
+            </gr-tooltip-content>
+            <gr-tooltip-content
+                has-tooltip
+                title$="[[task.icon.tooltip]]">
+                <iron-icon
+                  icon="[[task.icon.id]]"
+                  class$="[[task.icon.color]]">
+                </iron-icon>
+            </gr-tooltip-content>
+          </template>
+          [[task.message]]
+        </li>
       </template>
-      <gr-task-plugin-tasks tasks="[[task.sub_tasks]]"></gr-task-plugin-tasks>
+      <gr-task-plugin-tasks
+          tasks="[[task.sub_tasks]]"
+          show_all$="[[show_all]]"> </gr-task-plugin-tasks>
     </template>
   </template>
   <script>
@@ -49,6 +116,15 @@
             notify: true,
             value() { return []; },
           },
+
+          show_all: {
+            type: String,
+            notify: true,
+          },
+        },
+
+        _can_show(show, task) {
+          return show === 'true' || task.showOnFilter;
         },
       });
   </script>
diff --git a/gr-task-plugin/gr-task-plugin.js b/gr-task-plugin/gr-task-plugin.js
index 9fca732..3f284de 100644
--- a/gr-task-plugin/gr-task-plugin.js
+++ b/gr-task-plugin/gr-task-plugin.js
@@ -39,6 +39,40 @@
         notify: true,
         value() { return []; },
       },
+
+      _show_all: {
+        type: String,
+        notify: true,
+        value: 'false',
+      },
+
+      _expand_all: {
+        type: Boolean,
+        notify: true,
+        value: true,
+      },
+
+      _all_count: {
+        type: Number,
+        notify: true,
+        value: 0,
+      },
+
+      _ready_count: {
+        type: Number,
+        notify: true,
+        value: 0,
+      },
+
+      _fail_count: {
+        type: Number,
+        notify: true,
+        value: 0,
+      },
+    },
+
+    _is_show_all(show_all) {
+      return show_all === 'true';
     },
 
     attached() {
@@ -64,26 +98,82 @@
       });
     },
 
-    _getTaskDescription(task) {
-      return task.hint || task.name;
+    _computeIcon(task) {
+      const icon = {};
+      switch (task.status) {
+        case 'FAIL':
+          icon.id = 'gr-icons:close';
+          icon.color = 'red';
+          icon.tooltip = 'Failed';
+          break;
+        case 'READY':
+          icon.id = 'gr-icons:rebase';
+          icon.color = 'green';
+          icon.tooltip = 'Ready';
+          break;
+        case 'INVALID':
+          icon.id = 'gr-icons:abandon';
+          icon.color = 'red';
+          icon.tooltip = 'Invalid';
+          break;
+        case 'WAITING':
+          icon.id = 'gr-icons:side-by-side';
+          icon.color = 'red';
+          icon.tooltip = 'Waiting';
+          break;
+        case 'PASS':
+          icon.id = 'gr-icons:check';
+          icon.color = 'green';
+          icon.tooltip = 'Passed';
+          break;
+      }
+      return icon;
     },
 
-    _computeMessage(task) {
+    _computeShowOnNeedsAndBlockedFilter(task) {
       switch (task.status) {
         case 'FAIL':
         case 'READY':
         case 'INVALID':
-          return this._getTaskDescription(task);
+          return true;
+      }
+      return false;
+    },
+
+    _compute_counts(task) {
+      this._all_count++;
+      switch (task.status) {
+        case 'FAIL':
+          this._fail_count++;
+          break;
+        case 'READY':
+          this._ready_count++;
+          break;
       }
     },
 
     _addTasks(tasks) { // rename to process, remove DOM bits
       if (!tasks) return [];
       tasks.forEach(task => {
-        task.message = this._computeMessage(task);
+        task.message = task.hint || task.name;
+        task.icon = this._computeIcon(task);
+        task.showOnFilter = this._computeShowOnNeedsAndBlockedFilter(task);
+        this._compute_counts(task);
         this._addTasks(task.sub_tasks);
       });
       return tasks;
     },
+
+    _show_all_tap() {
+      this._show_all = 'true';
+    },
+
+    _needs_and_blocked_tap() {
+      this._show_all = 'false';
+    },
+
+    _switch_expand() {
+      this._expand_all = !this._expand_all;
+    },
   });
 })();
diff --git a/pom.xml b/pom.xml
deleted file mode 100644
index bb66e53..0000000
--- a/pom.xml
+++ /dev/null
@@ -1,88 +0,0 @@
-<?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>3.2.0-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>
-              <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.8</source>
-          <target>1.8</target>
-          <encoding>UTF-8</encoding>
-          <fork>true</fork>
-          <compilerArgs>
-            <arg>-XX:MaxPermSize=256m</arg>
-          </compilerArgs>
-        </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/googlesource/gerrit/plugins/task/TaskAttributeFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
index 1a5c702..54da2af 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
@@ -69,7 +69,8 @@
   protected final TaskTree definitions;
   protected final ChangeQueryBuilder cqb;
 
-  protected final Map<String, Predicate<ChangeData>> predicatesByQuery = new HashMap<>();
+  protected final Map<String, ThrowingProvider<Predicate<ChangeData>, QueryParseException>>
+      predicatesByQuery = new HashMap<>();
 
   protected Modules.MyOptions options;
 
@@ -86,11 +87,7 @@
       for (PatchSetArgument psa : options.patchSetArguments) {
         definitions.masquerade(psa);
       }
-      try {
-        return createWithExceptions(c);
-      } catch (StorageException e) {
-        log.atSevere().withCause(e).log("Cannot load tasks for: %s", c);
-      }
+      return createWithExceptions(c);
     }
     return null;
   }
@@ -154,7 +151,7 @@
           }
         }
       }
-    } catch (QueryParseException e) {
+    } catch (QueryParseException | RuntimeException e) {
       atts.add(invalid()); // bad applicability query
     }
   }
@@ -198,7 +195,7 @@
       match(c, def.fail);
       match(c, def.pass);
       return true;
-    } catch (StorageException | QueryParseException e) {
+    } catch (QueryParseException | RuntimeException e) {
       return false;
     }
   }
@@ -206,7 +203,7 @@
   protected Status getStatus(ChangeData c, Task def, TaskAttribute a) {
     try {
       return getStatusWithExceptions(c, def, a);
-    } catch (QueryParseException e) {
+    } catch (QueryParseException | RuntimeException e) {
       return Status.INVALID;
     }
   }
@@ -218,7 +215,8 @@
       boolean hasDefinedSubtasks =
           !(def.subTasks.isEmpty()
               && def.subTasksFiles.isEmpty()
-              && def.subTasksExternals.isEmpty());
+              && def.subTasksExternals.isEmpty()
+              && def.subTasksFactories.isEmpty());
       if (hasDefinedSubtasks) {
         // Remove 'Grouping" tasks (tasks with subtasks but no PASS
         // or FAIL criteria) from the output if none of their subtasks
@@ -295,31 +293,48 @@
     return true;
   }
 
-  protected boolean match(ChangeData c, String query) throws QueryParseException {
-    if (query == null || query.equalsIgnoreCase("true")) {
+  protected boolean match(ChangeData c, String query) throws StorageException, QueryParseException {
+    if (query == null) {
       return true;
     }
-    Predicate<ChangeData> pred = predicatesByQuery.get(query);
-    if (pred == null) {
-      pred = cqb.parse(query);
-      predicatesByQuery.put(query, pred);
-    }
-    return pred.asMatchable().match(c);
+    return matchWithExceptions(c, query);
   }
 
   protected Boolean matchOrNull(ChangeData c, String query) {
     if (query != null) {
       try {
-        if (query.equalsIgnoreCase("true")) {
-          return true;
-        }
-        return cqb.parse(query).asMatchable().match(c);
-      } catch (StorageException | QueryParseException e) {
+        return matchWithExceptions(c, query);
+      } catch (QueryParseException | RuntimeException e) {
       }
     }
     return null;
   }
 
+  protected boolean matchWithExceptions(ChangeData c, String query)
+      throws QueryParseException, StorageException {
+    if ("true".equalsIgnoreCase(query)) {
+      return true;
+    }
+    return getPredicate(query).asMatchable().match(c);
+  }
+
+  protected Predicate<ChangeData> getPredicate(String query) throws QueryParseException {
+    ThrowingProvider<Predicate<ChangeData>, QueryParseException> predProvider =
+        predicatesByQuery.get(query);
+    if (predProvider != null) {
+      return predProvider.get();
+    }
+    // never seen 'query' before
+    try {
+      Predicate<ChangeData> pred = cqb.parse(query);
+      predicatesByQuery.put(query, new ThrowingProvider.Entry<>(pred));
+      return pred;
+    } catch (QueryParseException e) {
+      predicatesByQuery.put(query, new ThrowingProvider.Thrown<>(e));
+      throw e;
+    }
+  }
+
   protected static boolean isAllNull(Object... vals) {
     for (Object val : vals) {
       if (val != null) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
index 6047616..a60569c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.Container;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.meta.AbstractVersionedMetaData;
+import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -38,7 +39,7 @@
     }
   }
 
-  public class Task extends Section {
+  public class TaskBase extends Section {
     public String applicable;
     public Map<String, String> exported;
     public String fail;
@@ -51,12 +52,13 @@
     public String readyHint;
     public List<String> subTasks;
     public List<String> subTasksExternals;
+    public List<String> subTasksFactories;
     public List<String> subTasksFiles;
 
     public boolean isVisible;
     public boolean isTrusted;
 
-    public Task(SubSection s, boolean isVisible, boolean isTrusted) {
+    public TaskBase(SubSection s, boolean isVisible, boolean isTrusted) {
       this.isVisible = isVisible;
       this.isTrusted = isTrusted;
       applicable = getString(s, KEY_APPLICABLE, null);
@@ -71,8 +73,52 @@
       readyHint = getString(s, KEY_READY_HINT, null);
       subTasks = getStringList(s, KEY_SUBTASK);
       subTasksExternals = getStringList(s, KEY_SUBTASKS_EXTERNAL);
+      subTasksFactories = getStringList(s, KEY_SUBTASKS_FACTORY);
       subTasksFiles = getStringList(s, KEY_SUBTASKS_FILE);
     }
+
+    protected TaskBase(TaskBase base) {
+      for (Field field : TaskBase.class.getDeclaredFields()) {
+        try {
+          field.setAccessible(true);
+          field.set(this, field.get(base));
+        } catch (IllegalAccessException e) {
+          throw new RuntimeException(e);
+        }
+      }
+    }
+  }
+
+  public class Task extends TaskBase {
+    public String name;
+
+    public Task(SubSection s, boolean isVisible, boolean isTrusted) {
+      super(s, isVisible, isTrusted);
+      name = getString(s, KEY_NAME, s.subSection);
+    }
+
+    protected Task(TaskBase base) {
+      super(base);
+    }
+  }
+
+  public class TasksFactory extends TaskBase {
+    public String namesFactory;
+
+    public TasksFactory(SubSection s, boolean isVisible, boolean isTrusted) {
+      super(s, isVisible, isTrusted);
+      namesFactory = getString(s, KEY_NAMES_FACTORY, null);
+    }
+  }
+
+  public class NamesFactory extends Section {
+    public List<String> names;
+    public String type;
+
+    public NamesFactory(SubSection s) {
+      names = getStringList(s, KEY_NAME);
+      type = getString(s, KEY_TYPE, null);
+    }
   }
 
   public class External extends Section {
@@ -91,8 +137,10 @@
       Pattern.compile("([^ |]*( *[^ |])*) *\\| *");
 
   protected static final String SECTION_EXTERNAL = "external";
+  protected static final String SECTION_NAMES_FACTORY = "names-factory";
   protected static final String SECTION_ROOT = "root";
   protected static final String SECTION_TASK = "task";
+  protected static final String SECTION_TASKS_FACTORY = "tasks-factory";
   protected static final String KEY_APPLICABLE = "applicable";
   protected static final String KEY_EXPORT_PREFIX = "export-";
   protected static final String KEY_FAIL = "fail";
@@ -100,18 +148,27 @@
   protected static final String KEY_FILE = "file";
   protected static final String KEY_IN_PROGRESS = "in-progress";
   protected static final String KEY_NAME = "name";
+  protected static final String KEY_NAMES_FACTORY = "names-factory";
   protected static final String KEY_PASS = "pass";
   protected static final String KEY_PRELOAD_TASK = "preload-task";
   protected static final String KEY_PROPERTIES_PREFIX = "set-";
   protected static final String KEY_READY_HINT = "ready-hint";
   protected static final String KEY_SUBTASK = "subtask";
   protected static final String KEY_SUBTASKS_EXTERNAL = "subtasks-external";
+  protected static final String KEY_SUBTASKS_FACTORY = "subtasks-factory";
   protected static final String KEY_SUBTASKS_FILE = "subtasks-file";
+  protected static final String KEY_TYPE = "type";
   protected static final String KEY_USER = "user";
 
   public boolean isVisible;
   public boolean isTrusted;
 
+  public Task createTask(TasksFactory tasks, String name) {
+    Task task = new Task(tasks);
+    task.name = name;
+    return task;
+  }
+
   public TaskConfig(BranchNameKey branch, String fileName, boolean isVisible, boolean isTrusted) {
     super(branch, fileName);
     this.isVisible = isVisible;
@@ -173,6 +230,14 @@
     return getNames(subSection).isEmpty() ? null : new Task(subSection, isVisible, isTrusted);
   }
 
+  public TasksFactory getTasksFactory(String name) {
+    return new TasksFactory(new SubSection(SECTION_TASKS_FACTORY, name), isVisible, isTrusted);
+  }
+
+  public NamesFactory getNamesFactory(String name) {
+    return new NamesFactory(new SubSection(SECTION_NAMES_FACTORY, name));
+  }
+
   public External getExternal(String name) {
     return getExternal(new SubSection(SECTION_EXTERNAL, name));
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
index 5cbb112..a15e8f5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
@@ -24,7 +24,9 @@
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.task.TaskConfig.External;
+import com.googlesource.gerrit.plugins.task.TaskConfig.NamesFactory;
 import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
+import com.googlesource.gerrit.plugins.task.TaskConfig.TasksFactory;
 import com.googlesource.gerrit.plugins.task.cli.PatchSetArgument;
 import java.io.IOException;
 import java.nio.file.Path;
@@ -131,6 +133,7 @@
 
     protected void addSubDefinitions() {
       addSubDefinitions(getSubDefinitions());
+      addSubDefinitions(getTasksFactoryDefinitions());
       addSubFileDefinitions();
       addExternalDefinitions();
     }
@@ -142,7 +145,7 @@
     protected void addSubFileDefinitions() {
       for (String file : definition.subTasksFiles) {
         try {
-          addSubDefinitions(getTasks(definition.config.getBranch(), file));
+          addSubDefinitions(getTaskDefinitions(definition.config.getBranch(), file));
         } catch (ConfigInvalidException | IOException e) {
           nodes.add(null);
         }
@@ -179,12 +182,30 @@
       return defs;
     }
 
-    protected List<Task> getTaskDefinitions(External external)
-        throws ConfigInvalidException, IOException {
-      return getTasks(resolveUserBranch(external.user), external.file);
+    protected List<Task> getTasksFactoryDefinitions() {
+      List<Task> taskList = new ArrayList<>();
+      for (String taskFactoryName : definition.subTasksFactories) {
+        TasksFactory tasksFactory = definition.config.getTasksFactory(taskFactoryName);
+        if (tasksFactory != null) {
+          NamesFactory namesFactory = definition.config.getNamesFactory(tasksFactory.namesFactory);
+          if (namesFactory != null && "static".equals(namesFactory.type)) {
+            for (String name : namesFactory.names) {
+              taskList.add(definition.config.createTask(tasksFactory, name));
+            }
+            continue;
+          }
+        }
+        taskList.add(null);
+      }
+      return taskList;
     }
 
-    protected List<Task> getTasks(BranchNameKey branch, String file)
+    protected List<Task> getTaskDefinitions(External external)
+        throws ConfigInvalidException, IOException {
+      return getTaskDefinitions(resolveUserBranch(external.user), external.file);
+    }
+
+    protected List<Task> getTaskDefinitions(BranchNameKey branch, String file)
         throws ConfigInvalidException, IOException {
       return taskFactory
           .getTaskConfig(branch, resolveTaskFileName(file), definition.isTrusted)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/ThrowingProvider.java b/src/main/java/com/googlesource/gerrit/plugins/task/ThrowingProvider.java
new file mode 100644
index 0000000..7644143
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/ThrowingProvider.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+public interface ThrowingProvider<V, E extends Exception> {
+  public V get() throws E;
+
+  public static class Entry<V, E extends Exception> implements ThrowingProvider<V, E> {
+    protected V entry;
+
+    public Entry(V entry) {
+      this.entry = entry;
+    }
+
+    @Override
+    public V get() {
+      return entry;
+    }
+  }
+
+  public static class Thrown<V, E extends Exception> implements ThrowingProvider<V, E> {
+    protected E exception;
+
+    public Thrown(E exception) {
+      this.exception = exception;
+    }
+
+    @Override
+    public V get() throws E {
+      throw exception;
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/task.md b/src/main/resources/Documentation/task.md
index 62297c5..9fe4e3d 100644
--- a/src/main/resources/Documentation/task.md
+++ b/src/main/resources/Documentation/task.md
@@ -181,6 +181,21 @@
     ...
 ```
 
+`subtasks-factory`
+
+: A subtasks-factory key specifies a task-factory, which generates zero or more
+tasks that are subtasks of the current task.  This key may be used several times
+in a task section to reference tasks-factory sections.
+
+Example:
+
+```
+    subtasks-factory = "static tasks factory"
+    ...
+    [tasks-factory "static tasks factory"]
+    ...
+```
+
 `subtasks-external`
 
 : This key defines a file containing subtasks of the current task. This
@@ -274,6 +289,69 @@
               Backup Optional Subtask {$_name} Backup |
               Default Subtask # Must exist if the above two don't!
 ```
+Tasks-Factory
+-------------
+A tasks-factory section supports all the keys supported by task sections.  In
+addition, this section must have a names-factory key which refers to a
+names-factory section.  In conjunction with the names-factory, a tasks-factory
+section creates zero or more task definitions that look like regular tasks,
+each with a name provided by the names-factory, and all using the task definition
+set in the tasks-factory.
+
+A tasks-factory section is referenced by a subtasks-factory key in a "task"
+section.  A sample task.config which defines a tasks-factory section might look
+like this:
+
+```
+[task "static task list"]
+    subtasks-factory = static tasks factory
+    ...
+
+[tasks-factory "static tasks factory"]
+    names-factory = static names factory list
+    ...
+```
+
+Names-Factory
+-------------
+A names-factory section defines a collection of name keys which are used to
+generate the names for task definitions.  The section should contain a "type"
+key that specifies the type.
+
+A names-factory section is referenced by a names-factory key in a "tasks-factory"
+section.  A sample task.config which defines a names-factory section might look like
+this:
+
+```
+[names-factory "static names factory list"]
+    name = my a task
+    name = my b task
+    type = static
+```
+
+The following keys may be defined in any names-factory section:
+
+`name`
+
+: This key defines the name of the tasks.  This key may be used several times
+in order to define more than one task. The name key can only be used along with
+names-factory of type `static`.
+
+Example:
+```
+    name = my a task
+    name = 12345
+```
+
+`type`
+
+: This key defines the type of the names-factory section.  The only
+accepted value is `static`.
+
+Example:
+```
+    type = static
+```
 
 External Entries
 ----------------
diff --git a/src/main/resources/Documentation/task_states.md b/src/main/resources/Documentation/task_states.md
index 721032f..58d6ca7 100644
--- a/src/main/resources/Documentation/task_states.md
+++ b/src/main/resources/Documentation/task_states.md
@@ -103,6 +103,16 @@
   subtasks-external = user special
   subtasks-external = file missing
 
+[root "Root tasks-factory"]
+  subtasks-factory = tasks-factory static
+
+[root "Root tasks-factory static (empty name)"]
+  subtasks-factory = tasks-factory static (empty name)
+
+[root "Root tasks-factory static (empty name PASS)"]
+  pass = True
+  subtasks-factory = tasks-factory static (empty name)
+
 [root "Root Properties"]
   set-root-property = root-value
   export-root = ${_name}
@@ -129,6 +139,14 @@
   applicable = NOT is:open # Assumes test query is "is:open"
   subtasks-file = invalids.config
 
+[tasks-factory "tasks-factory static"]
+  names-factory = names-factory static list
+  fail = True
+
+[tasks-factory "tasks-factory static (empty name)"]
+  names-factory = names-factory static (empty name list)
+  fail = True
+
 [task "Subtask APPLICABLE"]
   applicable = is:open
   pass = True
@@ -237,6 +255,16 @@
 [external "file missing"]
   user = testuser
   file = missing
+
+[names-factory "names-factory static list"]
+  name = my a task
+  name = my b task
+  name = my c task
+  type = static
+
+[names-factory "names-factory static (empty name list)"]
+  type = static
+
 ```
 
 `task/common.config` file in project `All-Projects` on ref `refs/meta/config`.
@@ -303,6 +331,30 @@
   set-A = ${B}
   set-B = ${A}
   fail = True
+
+[task "task (tasks-factory missing)"]
+  subtasks-factory = missing
+
+[task "task (names-factory type missing)"]
+  subtasks-factory = tasks-factory (names-factory type missing)
+
+[task "task (names-factory type INVALID)"]
+  subtasks-factory = tasks-factory (names-factory type INVALID)
+
+[tasks-factory "tasks-factory (names-factory type missing)"]
+  names-factory = names-factory (type missing)
+  fail = True
+
+[names-factory "names-factory (type missing)"]
+  name = no type test
+
+[tasks-factory "tasks-factory (names-factory type INVALID)"]
+  names-factory = name-factory (type INVALID)
+
+[names-factory "names-factory (type INVALID)"]
+  name = invalid type test
+  type = invalid
+
 ```
 
 `task/special.config` file in project `All-Users` on ref `refs/users/self`.
@@ -616,6 +668,33 @@
                ]
             },
             {
+               "hasPass" : false,
+               "name" : "Root tasks-factory",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "hasPass" : true,
+                     "name" : "my a task",
+                     "status" : "FAIL"
+                  },
+                  {
+                     "hasPass" : true,
+                     "name" : "my b task",
+                     "status" : "FAIL"
+                  },
+                  {
+                     "hasPass" : true,
+                     "name" : "my c task",
+                     "status" : "FAIL"
+                  }
+               ]
+            },
+            {
+               "hasPass" : true,
+               "name" : "Root tasks-factory static (empty name PASS)",
+               "status" : "PASS"
+            },
+            {
                "exported" : {
                   "root" : "Root Properties"
                },
@@ -828,6 +907,39 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
diff --git a/test/all b/test/all
index 82347ab..6e29127 100644
--- a/test/all
+++ b/test/all
@@ -359,6 +359,43 @@
             },
             {
                "applicable" : true,
+               "hasPass" : false,
+               "name" : "Root tasks-factory",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "hasPass" : true,
+                     "name" : "my a task",
+                     "status" : "FAIL"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : true,
+                     "name" : "my b task",
+                     "status" : "FAIL"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : true,
+                     "name" : "my c task",
+                     "status" : "FAIL"
+                  }
+               ]
+            },
+            {
+               "applicable" : true,
+               "hasPass" : false,
+               "name" : "Root tasks-factory static (empty name)"
+            },
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "name" : "Root tasks-factory static (empty name PASS)",
+               "status" : "PASS"
+            },
+            {
+               "applicable" : true,
                "exported" : {
                   "root" : "Root Properties"
                },
@@ -619,6 +656,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             },
@@ -767,6 +840,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
diff --git a/test/check_task_statuses.sh b/test/check_task_statuses.sh
index 44bb05b..0ed0dcb 100755
--- a/test/check_task_statuses.sh
+++ b/test/check_task_statuses.sh
@@ -1,7 +1,6 @@
 #!/bin/bash
 
 # Usage:
-# All-Users.git - refs/users/self must already exist
 # All-Projects.git - must have 'Push' rights on refs/meta/config
 
 # ---- TEST RESULTS ----
@@ -44,14 +43,18 @@
     chmod +x "$hook"
 }
 
-setup_repo() { # repo remote ref
-    local repo=$1 remote=$2 ref=$3
+setup_repo() { # repo remote ref [--initial-commit]
+    local repo=$1 remote=$2 ref=$3 init=$4
     git init "$repo"
     (
         cd "$repo"
         install_changeid_hook "$repo"
         git fetch "$remote" "$ref"
-        git checkout FETCH_HEAD
+        if ! git checkout FETCH_HEAD ; then
+            if [ "$init" = "--initial-commit" ] ; then
+                git commit --allow-empty -a -m "Initial Commit"
+            fi
+        fi
     )
 }
 
@@ -128,10 +131,11 @@
 REF_ALL=refs/meta/config
 REF_USERS=refs/users/self
 
+RESULT=0
 
 mkdir -p "$OUT"
 q_setup setup_repo "$ALL" "$REMOTE_ALL" "$REF_ALL"
-q_setup setup_repo "$USERS" "$REMOTE_USERS" "$REF_USERS"
+q_setup setup_repo "$USERS" "$REMOTE_USERS" "$REF_USERS" --initial-commit
 
 mkdir -p "$ALL_TASKS" "$USER_TASKS"
 
@@ -156,3 +160,5 @@
 
 test_file invalid --task--invalid "$query"
 test_file invalid-applicable --task--applicable --task--invalid "$query"
+
+exit $RESULT
diff --git a/test/docker/docker-compose.yaml b/test/docker/docker-compose.yaml
new file mode 100755
index 0000000..634cde4
--- /dev/null
+++ b/test/docker/docker-compose.yaml
@@ -0,0 +1,32 @@
+version: '3'
+services:
+
+  gerrit-01:
+    build:
+      context: gerrit
+      args:
+        - GERRIT_WAR
+        - TASK_PLUGIN_JAR
+    networks:
+      - gerrit-net
+    volumes:
+      - "gerrit-site-etc:/var/gerrit/etc"
+
+  run_tests:
+    build: run_tests
+    networks:
+      - gerrit-net
+    volumes:
+      - "../../:/task:ro"
+      - "gerrit-site-etc:/server-ssh-key:ro"
+    depends_on:
+      - gerrit-01
+    environment:
+      - GERRIT_HOST=gerrit-01
+
+networks:
+  gerrit-net:
+    driver: bridge
+
+volumes:
+  gerrit-site-etc:
diff --git a/test/docker/gerrit/Dockerfile b/test/docker/gerrit/Dockerfile
new file mode 100755
index 0000000..041e1dd
--- /dev/null
+++ b/test/docker/gerrit/Dockerfile
@@ -0,0 +1,13 @@
+FROM gerritcodereview/gerrit:3.1.12-ubuntu18
+
+USER root
+
+ENV GERRIT_SITE /var/gerrit
+RUN git config -f "$GERRIT_SITE/etc/gerrit.config" auth.type \
+    DEVELOPMENT_BECOME_ANY_ACCOUNT
+
+COPY artifacts /tmp/
+RUN cp /tmp/task.jar "$GERRIT_SITE/plugins/task.jar"
+RUN { [ -e /tmp/gerrit.war ] && cp /tmp/gerrit.war "$GERRIT_SITE/bin/gerrit.war" ; } || true
+
+USER gerrit
diff --git a/test/docker/run.sh b/test/docker/run.sh
new file mode 100755
index 0000000..9c1f5d9
--- /dev/null
+++ b/test/docker/run.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+
+readlink --canonicalize / &> /dev/null || readlink() { greadlink "$@" ; } # for MacOS
+MYDIR=$(dirname -- "$(readlink -f -- "$0")")
+ARTIFACTS=$MYDIR/gerrit/artifacts
+
+die() { echo -e "\nERROR: $@" ; kill $$ ; exit 1 ; } # error_message
+
+progress() { # message cmd [args]...
+    local message=$1 ; shift
+    echo -n "$message"
+    "$@" &
+    local pid=$!
+    while kill -0 $pid 2> /dev/null ; do
+        echo -n "."
+        sleep 2
+    done
+    echo
+    wait "$pid"
+}
+
+usage() { # [error_message]
+    local prog=$(basename -- "$0")
+
+    cat <<-EOF
+Usage:
+    $prog [--task-plugin-jar|-t <FILE_PATH>] [--gerrit-war|-g <FILE_PATH>]
+
+    This tool runs the plugin functional tests in a Docker environment built
+    from the gerritcodereview/gerrit base Docker image.
+
+    The task plugin JAR and optionally a Gerrit WAR are expected to be in the
+    $ARTIFACTS dir;
+    however, the --task-plugin-jar and --gerrit-war switches may be used as
+    helpers to specify which files to copy there.
+
+    Options:
+    --help|-h
+    --gerrit-war|-g            path to Gerrit WAR file
+    --task-plugin-jar|-t       path to task plugin JAR file
+
+EOF
+
+    [ -n "$1" ] && echo -e "\nERROR: $1" && exit 1
+    exit 0
+}
+
+check_prerequisite() {
+    docker --version > /dev/null || die "docker is not installed"
+    docker-compose --version > /dev/null || die "docker-compose is not installed"
+}
+
+build_images() {
+    docker-compose "${COMPOSE_ARGS[@]}" build --quiet
+}
+
+run_task_plugin_tests() {
+    docker-compose "${COMPOSE_ARGS[@]}" up --detach
+    docker-compose "${COMPOSE_ARGS[@]}" exec -T --user=gerrit_admin run_tests \
+        '/task/test/docker/run_tests/start.sh'
+}
+
+cleanup() {
+    docker-compose "${COMPOSE_ARGS[@]}" down -v --rmi local 2>/dev/null
+}
+
+while (( "$#" )) ; do
+    case "$1" in
+        --help|-h)                usage ;;
+        --gerrit-war|-g)          shift ; GERRIT_WAR=$1 ;;
+        --task-plugin-jar|-t)     shift ; TASK_PLUGIN_JAR=$1 ;;
+        *)                        usage "invalid argument $1" ;;
+    esac
+    shift
+done
+PROJECT_NAME="task_$$"
+COMPOSE_YAML="$MYDIR/docker-compose.yaml"
+COMPOSE_ARGS=(--project-name "$PROJECT_NAME" -f "$COMPOSE_YAML")
+check_prerequisite
+mkdir -p -- "$ARTIFACTS"
+[ -n "$TASK_PLUGIN_JAR" ] && cp -f "$TASK_PLUGIN_JAR" "$ARTIFACTS/task.jar"
+if [ ! -e "$ARTIFACTS/task.jar" ] ; then
+    MISSING="Missing $ARTIFACTS/task.jar"
+    [ -n "$TASK_PLUGIN_JAR" ] && die "$MISSING, check for copy failure?"
+    usage "$MISSING, did you forget --task-plugin-jar?"
+fi
+[ -n "$GERRIT_WAR" ] && cp -f "$GERRIT_WAR" "$ARTIFACTS/gerrit.war"
+progress "Building docker images" build_images
+run_task_plugin_tests ; RESULT=$?
+cleanup
+
+exit "$RESULT"
diff --git a/test/docker/run_tests/Dockerfile b/test/docker/run_tests/Dockerfile
new file mode 100755
index 0000000..aaeeb1d
--- /dev/null
+++ b/test/docker/run_tests/Dockerfile
@@ -0,0 +1,26 @@
+FROM alpine:3.11
+
+ARG UID=1000
+ARG GID=1000
+ENV USER gerrit_admin
+ENV USER_HOME /home/$USER
+ENV RUN_TESTS_DIR task/test/docker/run_tests
+ENV WORKSPACE $USER_HOME/workspace
+
+RUN apk --update add --no-cache openssh bash git python2 shadow
+RUN echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config
+
+RUN groupadd -f -g $GID users2
+RUN useradd -u $UID -g $GID $USER
+RUN mkdir -p $WORKSPACE $USER_HOME/.ssh
+RUN chown -R $USER $USER_HOME
+
+USER $USER
+
+RUN ssh-keygen -P '' -f "$USER_HOME"/.ssh/id_rsa
+RUN chmod 400 "$USER_HOME"/.ssh/id_rsa
+RUN chmod 400 "$USER_HOME"/.ssh/id_rsa.pub
+RUN git config --global user.name "Gerrit Admin"
+RUN git config --global user.email "gerrit_admin@localdomain"
+
+ENTRYPOINT ["tail", "-f", "/dev/null"]
diff --git a/test/docker/run_tests/create-test-project-and-changes.sh b/test/docker/run_tests/create-test-project-and-changes.sh
new file mode 100755
index 0000000..159882b
--- /dev/null
+++ b/test/docker/run_tests/create-test-project-and-changes.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+
+PORT=29418
+
+gssh() { ssh -x -p "$PORT" "$GERRIT_HOST" gerrit "$@" ; } # cmd [args]...
+
+create_project() { # project
+    echo "Creating a test project ..."
+    gssh create-project "$1" --owner "Administrators" --submit-type "MERGE_IF_NECESSARY"
+    cd "$WORKSPACE" && git clone ssh://"$GERRIT_HOST":"$PORT"/"$1" "$1" && cd "$1"
+    gitdir=$(git rev-parse --git-dir)
+    scp -p -P "$PORT" "$USER"@"$GERRIT_HOST":hooks/commit-msg "$gitdir"/hooks/
+}
+
+create_change() { # subject project
+    touch readme.txt && echo "$(date)" >> readme.txt
+    git add . && git commit -m "$1"
+    git push ssh://"$GERRIT_HOST":"$PORT"/"$2" HEAD:refs/for/master
+    commitRevision=$(git rev-parse HEAD)
+}
+
+submit_change() { # commit_revision
+    gssh review --code-review +2 --submit "$1"
+}
+
+create_project 'test'
+create_change 'Change 1' 'test'
+commit1Revision=$commitRevision
+create_change 'Change 2' 'test'
+#sleep to avoid race conditions
+sleep 60
+submit_change "$commit1Revision"
diff --git a/test/docker/run_tests/start.sh b/test/docker/run_tests/start.sh
new file mode 100755
index 0000000..9b40537
--- /dev/null
+++ b/test/docker/run_tests/start.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+
+USER_RUN_TESTS_DIR="$USER_HOME"/"$RUN_TESTS_DIR"
+cp -r /task "$USER_HOME"/
+
+./"$USER_RUN_TESTS_DIR"/wait-for-it.sh "$GERRIT_HOST":29418 -t 60 -- echo "gerrit is up"
+
+echo "Creating a default user account ..."
+
+cat "$USER_HOME"/.ssh/id_rsa.pub | ssh -p 29418 -i /server-ssh-key/ssh_host_rsa_key \
+  "Gerrit Code Review@$GERRIT_HOST" suexec --as "admin@example.com" -- gerrit create-account \
+     --ssh-key - --email "gerrit_admin@localdomain"  --group "Administrators" "gerrit_admin"
+
+./"$USER_RUN_TESTS_DIR"/create-test-project-and-changes.sh
+./"$USER_RUN_TESTS_DIR"/update-all-users-project.sh
+
+echo "Running Task plugin tests ..."
+cd "$USER_RUN_TESTS_DIR"/../../ && ./check_task_statuses.sh "$GERRIT_HOST"
diff --git a/test/docker/run_tests/update-all-users-project.sh b/test/docker/run_tests/update-all-users-project.sh
new file mode 100755
index 0000000..d0e1527
--- /dev/null
+++ b/test/docker/run_tests/update-all-users-project.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+echo "Updating All-Users project ..."
+
+cd "$WORKSPACE" && git clone ssh://"$GERRIT_HOST":29418/All-Users allusers && cd allusers
+git fetch origin refs/meta/config && git checkout FETCH_HEAD
+git config -f project.config access."refs/users/*".read "group Administrators"
+git config -f project.config access."refs/users/*".push "group Administrators"
+git config -f project.config access."refs/users/*".create "group Administrators"
+git add . && git commit -m "project config update" && git push origin HEAD:refs/meta/config
diff --git a/test/docker/run_tests/wait-for-it.sh b/test/docker/run_tests/wait-for-it.sh
new file mode 100755
index 0000000..d7b6e3c
--- /dev/null
+++ b/test/docker/run_tests/wait-for-it.sh
@@ -0,0 +1,162 @@
+#!/usr/bin/env bash
+# https://github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh
+#   Use this script to test if a given TCP host/port are available
+
+cmdname=$(basename $0)
+
+echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
+
+usage()
+{
+    cat << USAGE >&2
+Usage:
+    $cmdname host:port [-s] [-t timeout] [-- command args]
+    -h HOST | --host=HOST       Host or IP under test
+    -p PORT | --port=PORT       TCP port under test
+                                Alternatively, you specify the host and port as host:port
+    -s | --strict               Only execute subcommand if the test succeeds
+    -q | --quiet                Don't output any status messages
+    -t TIMEOUT | --timeout=TIMEOUT
+                                Timeout in seconds, zero for no timeout
+    -- COMMAND ARGS             Execute command with args after the test finishes
+USAGE
+    exit 1
+}
+
+wait_for()
+{
+    if [[ $TIMEOUT -gt 0 ]]; then
+        echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT"
+    else
+        echoerr "$cmdname: waiting for $HOST:$PORT without a timeout"
+    fi
+    start_ts=$(date +%s)
+    while :
+    do
+        (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1
+        result=$?
+        if [[ $result -eq 0 ]]; then
+            end_ts=$(date +%s)
+            echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds"
+            break
+        fi
+        sleep 1
+    done
+    return $result
+}
+
+wait_for_wrapper()
+{
+    # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
+    if [[ $QUIET -eq 1 ]]; then
+        timeout $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
+    else
+        timeout $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
+    fi
+    PID=$!
+    trap "kill -INT -$PID" INT
+    wait $PID
+    RESULT=$?
+    if [[ $RESULT -ne 0 ]]; then
+        echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT"
+    fi
+    return $RESULT
+}
+
+# process arguments
+while [[ $# -gt 0 ]]
+do
+    case "$1" in
+        *:* )
+        hostport=(${1//:/ })
+        HOST=${hostport[0]}
+        PORT=${hostport[1]}
+        shift 1
+        ;;
+        --child)
+        CHILD=1
+        shift 1
+        ;;
+        -q | --quiet)
+        QUIET=1
+        shift 1
+        ;;
+        -s | --strict)
+        STRICT=1
+        shift 1
+        ;;
+        -h)
+        HOST="$2"
+        if [[ $HOST == "" ]]; then break; fi
+        shift 2
+        ;;
+        --host=*)
+        HOST="${1#*=}"
+        shift 1
+        ;;
+        -p)
+        PORT="$2"
+        if [[ $PORT == "" ]]; then break; fi
+        shift 2
+        ;;
+        --port=*)
+        PORT="${1#*=}"
+        shift 1
+        ;;
+        -t)
+        TIMEOUT="$2"
+        if [[ $TIMEOUT == "" ]]; then break; fi
+        shift 2
+        ;;
+        --timeout=*)
+        TIMEOUT="${1#*=}"
+        shift 1
+        ;;
+        --)
+        shift
+        CLI="$@"
+        break
+        ;;
+        --help)
+        usage
+        ;;
+        *)
+        echoerr "Unknown argument: $1"
+        usage
+        ;;
+    esac
+done
+
+if [[ "$HOST" == "" || "$PORT" == "" ]]; then
+    echoerr "Error: you need to provide a host and port to test."
+    usage
+fi
+
+TIMEOUT=${TIMEOUT:-15}
+STRICT=${STRICT:-0}
+CHILD=${CHILD:-0}
+QUIET=${QUIET:-0}
+
+if [[ $CHILD -gt 0 ]]; then
+    wait_for
+    RESULT=$?
+    exit $RESULT
+else
+    if [[ $TIMEOUT -gt 0 ]]; then
+        wait_for_wrapper
+        RESULT=$?
+    else
+        wait_for
+        RESULT=$?
+    fi
+fi
+
+if [[ $CLI != "" ]]; then
+    if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then
+        echoerr "$cmdname: strict mode, refusing to execute subprocess"
+        exit $RESULT
+    fi
+    exec $CLI
+else
+    exit $RESULT
+fi
diff --git a/test/invalid b/test/invalid
index 840c3da..adab1d1 100644
--- a/test/invalid
+++ b/test/invalid
@@ -179,6 +179,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             },
@@ -315,6 +351,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
diff --git a/test/invalid-applicable b/test/invalid-applicable
index 8e52fa0..449e595 100644
--- a/test/invalid-applicable
+++ b/test/invalid-applicable
@@ -126,6 +126,39 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
diff --git a/test/preview b/test/preview
index e2706ea..e74d35d 100644
--- a/test/preview
+++ b/test/preview
@@ -135,6 +135,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             },
@@ -318,6 +354,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
diff --git a/test/preview.invalid b/test/preview.invalid
index 86d12f1..056bb80 100644
--- a/test/preview.invalid
+++ b/test/preview.invalid
@@ -135,6 +135,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             },
@@ -271,6 +307,42 @@
                   {
                      "name" : "UNKNOWN",
                      "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (tasks-factory missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "hasPass" : false,
+                     "name" : "task (names-factory type INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "name" : "UNKNOWN",
+                           "status" : "INVALID"
+                        }
+                     ]
                   }
                ]
             }
diff --git a/tools/playbooks/install_docker.yaml b/tools/playbooks/install_docker.yaml
new file mode 100644
index 0000000..89cf315
--- /dev/null
+++ b/tools/playbooks/install_docker.yaml
@@ -0,0 +1,8 @@
+- hosts: all
+  roles:
+    - name: ensure-docker
+  tasks:
+    - name: Install compose
+      shell: |
+        sudo curl -L "https://github.com/docker/compose/releases/download/1.29.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
+        sudo chmod +x /usr/local/bin/docker-compose