Merge "Add counts to Task headers" into stable-2.16
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 3f724db..f5ca52c 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-PluginName: " + plugin_name,
         "Gerrit-ApiVersion: 2.16",
         "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",
         "Gerrit-SshModule: com.googlesource.gerrit.plugins.task.Modules$SshModule",
         "Gerrit-HttpModule: com.googlesource.gerrit.plugins.task.Modules$HttpModule",
@@ -42,3 +44,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/pom.xml b/pom.xml
deleted file mode 100644
index dc48bcf..0000000
--- a/pom.xml
+++ /dev/null
@@ -1,90 +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>2.16</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.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 ee40d52..1bdf987 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,16 +87,12 @@
       for (PatchSetArgument psa : options.patchSetArguments) {
         definitions.masquerade(psa);
       }
-      try {
         return createWithExceptions(c);
-      } catch (OrmException e) {
-        log.atSevere().withCause(e).log("Cannot load tasks for: %s", c);
-      }
     }
     return null;
   }
 
-  protected PluginDefinedInfo createWithExceptions(ChangeData c) throws OrmException {
+  protected PluginDefinedInfo createWithExceptions(ChangeData c) {
     TaskPluginAttribute a = new TaskPluginAttribute();
     try {
       for (Node node : definitions.getRootNodes()) {
@@ -111,8 +108,7 @@
     return a;
   }
 
-  protected void addApplicableTasks(List<TaskAttribute> atts, ChangeData c, Node node)
-      throws OrmException {
+  protected void addApplicableTasks(List<TaskAttribute> atts, ChangeData c, Node node) {
     try {
       Task def = node.definition;
       TaskAttribute att = new TaskAttribute(def.name);
@@ -155,7 +151,7 @@
           }
         }
       }
-    } catch (QueryParseException e) {
+    } catch (OrmException | QueryParseException | RuntimeException e) {
       atts.add(invalid()); // bad applicability query
     }
   }
@@ -199,15 +195,15 @@
       match(c, def.fail);
       match(c, def.pass);
       return true;
-    } catch (OrmException | QueryParseException e) {
+    } catch (OrmException | QueryParseException | RuntimeException e) {
       return false;
     }
   }
 
-  protected Status getStatus(ChangeData c, Task def, TaskAttribute a) throws OrmException {
+  protected Status getStatus(ChangeData c, Task def, TaskAttribute a) {
     try {
       return getStatusWithExceptions(c, def, a);
-    } catch (QueryParseException e) {
+    } catch (OrmException | QueryParseException | RuntimeException e) {
       return Status.INVALID;
     }
   }
@@ -219,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
@@ -297,30 +294,47 @@
   }
 
   protected boolean match(ChangeData c, String query) throws OrmException, QueryParseException {
-    if (query == null || query.equalsIgnoreCase("true")) {
+    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 (OrmException | QueryParseException e) {
+        return matchWithExceptions(c, query);
+      } catch (OrmException | QueryParseException | RuntimeException e) {
       }
     }
     return null;
   }
 
+  protected boolean matchWithExceptions(ChangeData c, String query)
+      throws QueryParseException, OrmException {
+    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 5fcc8f1..ef23533 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.reviewdb.client.Branch;
 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(Branch.NameKey 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 ddde9c5..99a1ba9 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.gwtorm.server.OrmException;
 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() throws OrmException {
       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, OrmException {
-      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(Branch.NameKey branch, String file)
+    protected List<Task> getTaskDefinitions(External external)
+        throws ConfigInvalidException, IOException, OrmException {
+      return getTaskDefinitions(resolveUserBranch(external.user), external.file);
+    }
+
+    protected List<Task> getTaskDefinitions(Branch.NameKey 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 31e2d46..3b2bf0b 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/gerrit/Dockerfile b/test/docker/gerrit/Dockerfile
index 847ce34..059f3c0 100755
--- a/test/docker/gerrit/Dockerfile
+++ b/test/docker/gerrit/Dockerfile
@@ -1,22 +1,13 @@
-FROM openjdk:8
-ARG GERRIT_WAR
-ARG TASK_PLUGIN_JAR
-ARG UID=1000
-ARG GID=1000
+FROM gerritcodereview/gerrit:2.16.27-ubuntu16
 
-ENV GERRIT_USER gerrit
+USER root
+
 ENV GERRIT_SITE /var/gerrit
-ENV USER_HOME /home/$GERRIT_USER
-RUN mkdir -p $GERRIT_SITE/bin $GERRIT_SITE/plugins $GERRIT_SITE/etc $USER_HOME/.ssh
-COPY $GERRIT_WAR $GERRIT_SITE/bin/gerrit.war
-COPY $TASK_PLUGIN_JAR $GERRIT_SITE/plugins/task.jar
-RUN touch $GERRIT_SITE/etc/gerrit.config && \
-    git config -f $GERRIT_SITE/etc/gerrit.config auth.type DEVELOPMENT_BECOME_ANY_ACCOUNT
+RUN git config -f "$GERRIT_SITE/etc/gerrit.config" auth.type \
+    DEVELOPMENT_BECOME_ANY_ACCOUNT
 
-EXPOSE 29418 8080
-RUN groupadd -f -g $GID users2 && \
-  useradd -u $UID -g $GID $GERRIT_USER && \
-  chown -R $GERRIT_USER $GERRIT_SITE $USER_HOME
+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_USER
-ENTRYPOINT ["/docker/gerrit_start.sh"]
+USER gerrit
diff --git a/test/docker/run.sh b/test/docker/run.sh
index 71b8a55..9c1f5d9 100755
--- a/test/docker/run.sh
+++ b/test/docker/run.sh
@@ -20,18 +20,24 @@
 }
 
 usage() { # [error_message]
-    local prog=$(basename "$0")
+    local prog=$(basename -- "$0")
 
     cat <<-EOF
 Usage:
-    "$prog" --gerrit-war|-g <Gerrit WAR URL or file path> \
-        --task-plugin-jar|-t <task plugin URL or file path>
+    $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            gerrit WAR URL (or) the file path in local workspace
-                               eg: file:///path/to/source/file
-    --task-plugin-jar|-t       task plugin JAR URL (or) the file path in local workspace
-                               eg: file:///path/to/source/file
+    --gerrit-war|-g            path to Gerrit WAR file
+    --task-plugin-jar|-t       path to task plugin JAR file
 
 EOF
 
@@ -44,26 +50,13 @@
     docker-compose --version > /dev/null || die "docker-compose is not installed"
 }
 
-fetch_artifact() { # source_location output_path
-    curl --silent --fail --netrc "$1" --output "$2" --create-dirs || die "unable to fetch $1"
-}
-
-fetch_artifacts() {
-    fetch_artifact "$GERRIT_WAR" "$ARTIFACTS/gerrit.war"
-    fetch_artifact "$TASK_PLUGIN_JAR" "$ARTIFACTS/task.jar"
-}
-
 build_images() {
-    local build_args=(--build-arg GERRIT_WAR="/artifacts/gerrit.war" \
-        --build-arg TASK_PLUGIN_JAR="/artifacts/task.jar" \
-        --build-arg UID="$(id -u)" --build-arg GID="$(id -g)")
-    docker-compose "${COMPOSE_ARGS[@]}" build "${build_args[@]}" --quiet
-    rm -r "$ARTIFACTS"
+    docker-compose "${COMPOSE_ARGS[@]}" build --quiet
 }
 
 run_task_plugin_tests() {
     docker-compose "${COMPOSE_ARGS[@]}" up --detach
-    docker-compose "${COMPOSE_ARGS[@]}" exec --user=gerrit_admin run_tests \
+    docker-compose "${COMPOSE_ARGS[@]}" exec -T --user=gerrit_admin run_tests \
         '/task/test/docker/run_tests/start.sh'
 }
 
@@ -80,13 +73,18 @@
     esac
     shift
 done
-[ -n "$GERRIT_WAR" ] || usage "'--gerrit-war' not set"
-[ -n "$TASK_PLUGIN_JAR" ] || usage "'--task-plugin-jar' not set"
 PROJECT_NAME="task_$$"
 COMPOSE_YAML="$MYDIR/docker-compose.yaml"
 COMPOSE_ARGS=(--project-name "$PROJECT_NAME" -f "$COMPOSE_YAML")
 check_prerequisite
-progress "fetching artifacts" fetch_artifacts
+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
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