Add support for external tasks

The new 'subtasks-external' keyword adds all the subtasks from another
file referenced by a new 'external' section. The 'external' section
supports the 'user' and 'file' keywords to reference a file under the
task directory on a user's ref in the All-Users repo.

Change-Id: Id53265e596057f8495eb1c233b2679a06ca506d4
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 8ebb50f..95439e2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
@@ -17,13 +17,18 @@
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor.ChangeAttributeFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.task.TaskConfig.External;
 import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
 import java.io.IOException;
 import java.nio.file.Path;
@@ -64,11 +69,19 @@
 
   protected static final String TASK_DIR = "task";
 
+  protected final AccountResolver accountResolver;
+  protected final AllUsersNameProvider allUsers;
   protected final TaskConfigFactory taskFactory;
   protected final ChangeQueryBuilder cqb;
 
   @Inject
-  public TaskAttributeFactory(TaskConfigFactory taskFactory, ChangeQueryBuilder cqb) {
+  public TaskAttributeFactory(
+      AccountResolver accountResolver,
+      AllUsersNameProvider allUsers,
+      TaskConfigFactory taskFactory,
+      ChangeQueryBuilder cqb) {
+    this.accountResolver = accountResolver;
+    this.allUsers = allUsers;
     this.taskFactory = taskFactory;
     this.cqb = cqb;
   }
@@ -149,6 +162,18 @@
         subTasks.add(invalid());
       }
     }
+    for (String external : parent.subTasksExternals) {
+      try {
+        External ext = parent.config.getExternal(external);
+        if (ext == null) {
+          subTasks.add(invalid());
+        } else {
+          tasks.addAll(getTasks(ext));
+        }
+      } catch (ConfigInvalidException | IOException e) {
+        subTasks.add(invalid());
+      }
+    }
 
     for (Task task : tasks) {
       addApplicableTasks(subTasks, c, path, task);
@@ -180,6 +205,11 @@
     return tasks;
   }
 
+  protected List<Task> getTasks(External external)
+      throws ConfigInvalidException, IOException, OrmException {
+    return getTasks(resolveUserBranch(external.user), resolveTaskFileName(external.file));
+  }
+
   protected List<Task> getTasks(Branch.NameKey branch, String file)
       throws ConfigInvalidException, IOException {
     return taskFactory.getTaskConfig(branch, file).getTasks();
@@ -196,6 +226,18 @@
     return p.toString();
   }
 
+  protected Branch.NameKey resolveUserBranch(String user)
+      throws ConfigInvalidException, IOException, OrmException {
+    if (user == null) {
+      throw new ConfigInvalidException("External user not defined");
+    }
+    Account acct = accountResolver.find(user);
+    if (acct == null) {
+      throw new ConfigInvalidException("Cannot resolve user: " + user);
+    }
+    return new Branch.NameKey(allUsers.get(), RefNames.refsUsers(acct.getId()));
+  }
+
   protected Status getStatus(ChangeData c, Task task, TaskAttribute a)
       throws OrmException, QueryParseException {
     if (task.pass == null && a.subTasks == 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 d1902b3..86d6a2e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
@@ -39,6 +39,7 @@
     public String pass;
     public String readyHint;
     public List<String> subTasks;
+    public List<String> subTasksExternals;
     public List<String> subTasksFiles;
 
     public Task(SubSection s) {
@@ -49,20 +50,37 @@
       pass = getString(s, KEY_PASS, null);
       readyHint = getString(s, KEY_READY_HINT, null);
       subTasks = getStringList(s, KEY_SUBTASK);
+      subTasksExternals = getStringList(s, KEY_SUBTASKS_EXTERNAL);
       subTasksFiles = getStringList(s, KEY_SUBTASKS_FILE);
     }
   }
 
+  public class External extends Section {
+    public String name;
+    public String file;
+    public String user;
+
+    public External(SubSection s) {
+      name = s.subSection;
+      file = getString(s, KEY_FILE, null);
+      user = getString(s, KEY_USER, null);
+    }
+  }
+
+  protected static final String SECTION_EXTERNAL = "external";
   protected static final String SECTION_ROOT = "root";
   protected static final String SECTION_TASK = "task";
   protected static final String KEY_APPLICABLE = "applicable";
   protected static final String KEY_FAIL = "fail";
+  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_PASS = "pass";
   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_FILE = "subtasks-file";
+  protected static final String KEY_USER = "user";
 
   public TaskConfig(Branch.NameKey branch, String fileName) {
     super(branch, fileName);
@@ -85,10 +103,27 @@
     return tasks;
   }
 
+  public List<External> getExternals() {
+    List<External> externals = new ArrayList<>();
+    // No need to get an external with no name (what would we call it?)
+    for (String external : cfg.getSubsections(SECTION_EXTERNAL)) {
+      externals.add(getExternal(external));
+    }
+    return externals;
+  }
+
   public Task getTask(String name) {
     return new Task(new SubSection(SECTION_TASK, name));
   }
 
+  public External getExternal(String name) {
+    return getExternal(new SubSection(SECTION_EXTERNAL, name));
+  }
+
+  protected External getExternal(SubSection s) {
+    return new External(s);
+  }
+
   protected String getString(SubSection s, String key, String def) {
     String v = cfg.getString(s.section, s.subSection, key);
     return v != null ? v : def;
diff --git a/src/main/resources/Documentation/task.md b/src/main/resources/Documentation/task.md
index 2b3ff24..439580c 100644
--- a/src/main/resources/Documentation/task.md
+++ b/src/main/resources/Documentation/task.md
@@ -36,6 +36,12 @@
 can do, or perhaps who they need to talk to to ensure that their changes do
 make progress through all their hoops.
 
+Task definitions can be split up across multiple files/refs, and even
+across multiple projects. This splitting of task definitions allows the
+control of task definitions to be delegated to different entities. By
+aligning ref boundaries with controlling entities, the standard gerrit ref
+ACL mechanisms may be used to control who can define tasks on which changes.
+
 Task Status
 -----------
 Task status is used to indicate either the readiness of a task for execution
@@ -132,6 +138,20 @@
     subtask = "License Approval"
 ```
 
+`subtasks-external`
+
+: This key defines a file containing subtasks of the current task. This
+key may be used several times in a task section to define more than one file
+containing subtasks for a particular task. The subtasks-external key points
+to an external file defined by external section. Note: all of the tasks in
+the referenced file will be included as subtasks of the current task!
+
+Example:
+
+```
+    subtasks-external = my-external
+```
+
 `subtasks-file`
 
 : This key defines a file containing subtasks of the current task. This
@@ -191,6 +211,36 @@
     fail = label:code-review-2
 ```
 
+External Entries
+----------------
+A name for external task files on other projects and branches may be given
+by defining an `external` section in a task file. This later allows this
+external name to then be referenced by other definitions. The following
+keys may be defined in an external section. External references are limited
+to files under the top level task directory.
+
+`file`
+
+: This key defines the name of the external task file under the
+task directory referenced.
+
+Example:
+
+```
+    file = common.config  # references the file named task/common.config
+```
+
+`user`
+
+: This key defines the username of the user's ref in the `All-Users` project
+of the external file referenced.
+
+Example:
+
+```
+    user = first-user # references the sharded user ref refs/users/01/1000001
+```
+
 Change Query Output
 -------------------
 Changes which have tasks applicable to them will have a "task" section
diff --git a/src/main/resources/Documentation/task_states.md b/src/main/resources/Documentation/task_states.md
index a87c82c..ec5c4f8 100644
--- a/src/main/resources/Documentation/task_states.md
+++ b/src/main/resources/Documentation/task_states.md
@@ -87,6 +87,25 @@
   subtasks-file = common.config
   subtasks-file = missing
 
+[root "Subtasks External"]
+  applicable = is:open
+  subtasks-external = user special
+
+[root "Subtasks External (Missing)"]
+  applicable = is:open
+  subtasks-external = user special
+  subtasks-external = missing
+
+[root "Subtasks External (User Missing)"]
+  applicable = is:open
+  subtasks-external = user special
+  subtasks-external = user missing
+
+[root "Subtasks External (File Missing)"]
+  applicable = is:open
+  subtasks-external = user special
+  subtasks-external = file missing
+
 [task "Subtask FAIL"]
   applicable = is:open
   fail = is:open
@@ -103,6 +122,18 @@
 
 [task "Subtask INVALID"]
   applicable = is:open
+
+[external "user special"]
+  user = current-user
+  file = special.config
+
+[external "user missing"]
+  user = missing
+  file = special.config
+
+[external "file missing"]
+  user = current-user
+  file = missing
 ```
 
 `task/common.config` file in project `All-Projects` on ref `refs/meta/config`.
@@ -118,6 +149,19 @@
   pass = is:open
 ```
 
+`task/special.config` file in project `All-Users` on ref `refs/users/self`.
+
+```
+[task "userfile task/special.config PASS"]
+  applicable = is:open
+  pass = is:open
+
+[task "userfile task/special.config FAIL"]
+  applicable = is:open
+  fail = is:open
+  pass = is:open
+```
+
 The expected output for the above task config looks like:
 
 ```
@@ -291,6 +335,70 @@
                      "status" : "FAIL"
                   }
                ]
+            },
+            {
+               "name" : "Subtasks External",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "name" : "userfile task/special.config PASS",
+                     "status" : "PASS"
+                  },
+                  {
+                     "name" : "userfile task/special.config FAIL",
+                     "status" : "FAIL"
+                  }
+               ]
+            },
+            {
+               "name" : "Subtasks External (Missing)",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "name" : "UNKNOWN",
+                     "status" : "INVALID"
+                  },
+                  {
+                     "name" : "userfile task/special.config PASS",
+                     "status" : "PASS"
+                  },
+                  {
+                     "name" : "userfile task/special.config FAIL",
+                     "status" : "FAIL"
+                  }
+               ]
+            },
+            {
+               "name" : "Subtasks External (User Missing)",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "name" : "UNKNOWN",
+                     "status" : "INVALID"
+                  },
+                  {
+                     "name" : "userfile task/special.config PASS",
+                     "status" : "PASS"
+                  },
+                  {
+                     "name" : "userfile task/special.config FAIL",
+                     "status" : "FAIL"
+                  }
+               ]
+            },
+            {
+               "name" : "Subtasks External (File Missing)",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "name" : "userfile task/special.config PASS",
+                     "status" : "PASS"
+                  },
+                  {
+                     "name" : "userfile task/special.config FAIL",
+                     "status" : "FAIL"
+                  }
+               ]
             }
          ]
       }
diff --git a/test/check_task_statuses.sh b/test/check_task_statuses.sh
index b3af567..16167a4 100755
--- a/test/check_task_statuses.sh
+++ b/test/check_task_statuses.sh
@@ -1,6 +1,7 @@
 #!/bin/bash
 
 # Usage:
+# All-Users.git - refs/users/self must already exist
 # All-Projects.git - must have 'Push' rights on refs/meta/config
 
 example() { # example_num
@@ -43,12 +44,16 @@
 ALL=$OUT/All-Projects
 ALL_TASKS=$ALL/task
 
+USERS=$OUT/All-Users
+USER_TASKS=$USERS/task
+
 DOC_STATES=$DOCS/task_states.md
 EXPECTED=$OUT/expected
 STATUSES=$OUT/statuses
 
 ROOT_CFG=$ALL/task.config
 COMMON_CFG=$ALL_TASKS/common.config
+USER_SPECIAL_CFG=$USER_TASKS/special.config
 
 # --- Args ----
 SERVER=$1
@@ -56,21 +61,27 @@
 
 PORT=29418
 REMOTE_ALL=ssh://$SERVER:$PORT/All-Projects
+REMOTE_USERS=ssh://$SERVER:$PORT/All-Users
 
 REF_ALL=refs/meta/config
+REF_USERS=refs/users/self
 
 
 mkdir -p "$OUT"
 setup_repo "$ALL" "$REMOTE_ALL" "$REF_ALL"
+setup_repo "$USERS" "$REMOTE_USERS" "$REF_USERS"
 
-mkdir -p "$ALL_TASKS"
+mkdir -p "$ALL_TASKS" "$USER_TASKS"
 
-example 1 > "$ROOT_CFG"
+example 1 |sed -e"s/current-user/$USER/" > "$ROOT_CFG"
 example 2 > "$COMMON_CFG"
+example 3 > "$USER_SPECIAL_CFG"
 
 update_repo "$ALL" "$REMOTE_ALL" "$REF_ALL"
+update_repo "$USERS" "$REMOTE_USERS" "$REF_USERS"
 
-example 3 |tail -n +5| awk 'NR>1{print P};{P=$0}' > "$EXPECTED"
+
+example 4 |tail -n +5| awk 'NR>1{print P};{P=$0}' > "$EXPECTED"
 
 query_plugins "status:open limit:1" > "$STATUSES"
 diff "$EXPECTED" "$STATUSES"