Support loading subtasks from a different file

This is an incremental step to completely support moving
non root tasks away from task.config in All-Projects repo.
Various syntaxes were introduced to import a single task
from other files in same ref and are described in the
documentation.

Originally-Authored-By: Adithya Chakilam
Change-Id: I794e6a2b6d8c93e92e633c12823c956128e2ffe6
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
index dd6ab9d..5240ccc 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
@@ -37,9 +37,6 @@
 public class TaskConfigFactory {
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
 
-  protected static final String EXTENSION = ".config";
-  protected static final String DEFAULT = "task" + EXTENSION;
-
   protected final GitRepositoryManager gitMgr;
   protected final PermissionBackend permissionBackend;
 
@@ -62,7 +59,7 @@
   }
 
   public TaskConfig getRootConfig() throws ConfigInvalidException, IOException {
-    return getTaskConfig(FileKey.create(getRootBranch(), DEFAULT));
+    return getTaskConfig(FileKey.create(getRootBranch(), TaskFileConstants.TASK_CFG));
   }
 
   public void masquerade(PatchSetArgument psa) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
index 5a61d29..e47e880 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpression.java
@@ -23,16 +23,30 @@
  * A TaskExpression represents a config string pointing to an expression which includes zero or more
  * task names separated by a '|', and potentially termintated by a '|'. If the expression is not
  * terminated by a '|' it indicates that task resolution of at least one task is required. Task
- * selection priority is from left to right. This can be expressed as: <code>
- * EXPR = [ TASK_NAME '|' ] TASK_NAME [ '|' ]</code>
+ * selection priority is from left to right. This can be expressed as:
+ *
+ * <pre>
+ * TASK_REF = [ [ TASK_FILE_PATH ] '^' ] TASK_NAME
+ * TASK_EXPR = TASK_REF [ WHITE_SPACE * '|' [ WHITE_SPACE * TASK_EXPR ] ]
+ * </pre>
  *
  * <p>Example expressions to prioritized names and requirements:
  *
  * <ul>
- *   <li><code> "simple"        -> ("simple")         required</code>
- *   <li><code> "world | peace" -> ("world", "peace") required</code>
- *   <li><code> "shadenfreud |" -> ("shadenfreud")    optional</code>
- *   <li><code> "foo | bar |"   -> ("foo", "bar")     optional</code>
+ *   <li>
+ *       <pre> "simple"            -> ("simple")                       required</pre>
+ *   <li>
+ *       <pre> "world | peace"     -> ("world", "peace")               required</pre>
+ *   <li>
+ *       <pre> "shadenfreud |"     -> ("shadenfreud")                  optional</pre>
+ *   <li>
+ *       <pre> "foo | bar |"       -> ("foo", "bar")                   optional</pre>
+ *   <li>
+ *       <pre> "/foo^bar | baz |"  -> ("task/foo^bar", "baz")          optional</pre>
+ *   <li>
+ *       <pre> "foo^bar | baz |"   -> ("cur_dir/foo^bar", "baz")       optional</pre>
+ *   <li>
+ *       <pre> "^bar | baz |"      -> ("task.config^bar", "baz")       optional</pre>
  * </ul>
  */
 public class TaskExpression implements Iterable<TaskKey> {
@@ -72,7 +86,7 @@
           throw new NoSuchElementException("No more names, yet expression was not optional");
         }
         hasNext = null;
-        return TaskKey.create(key.file(), m.group(1).trim());
+        return new TaskReference(key.file(), m.group(1)).getTaskKey();
       }
     };
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java
index 0a05b2e..6fcd30d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskExpressionKey.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.task;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.BranchNameKey;
 
 /** A key for TaskExpression. */
 @AutoValue
@@ -23,6 +24,10 @@
     return new AutoValue_TaskExpressionKey(file, expression);
   }
 
+  public BranchNameKey branch() {
+    return file().branch();
+  }
+
   public abstract FileKey file();
 
   public abstract String expression();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskFileConstants.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskFileConstants.java
new file mode 100644
index 0000000..904c933
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskFileConstants.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2022 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 final class TaskFileConstants {
+  public static final String TASK_DIR = "task";
+  public static final String TASK_CFG = "task.config";
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/TaskReference.java b/src/main/java/com/googlesource/gerrit/plugins/task/TaskReference.java
new file mode 100644
index 0000000..2463a0c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskReference.java
@@ -0,0 +1,67 @@
+// 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;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.NoSuchElementException;
+
+/** This class is used by TaskExpression to decode the task from task reference. */
+public class TaskReference {
+  protected FileKey currentFile;
+  protected String reference;
+
+  public TaskReference(FileKey originalFile, String reference) {
+    currentFile = originalFile;
+    this.reference = reference.trim();
+    if (reference.isEmpty()) {
+      throw new NoSuchElementException();
+    }
+  }
+
+  public TaskKey getTaskKey() {
+    String[] referenceSplit = reference.split("\\^");
+    switch (referenceSplit.length) {
+      case 1:
+        return TaskKey.create(currentFile, referenceSplit[0]);
+      case 2:
+        return TaskKey.create(getFileKey(referenceSplit[0]), referenceSplit[1]);
+      default:
+        throw new NoSuchElementException();
+    }
+  }
+
+  protected FileKey getFileKey(String referenceFile) {
+    return FileKey.create(currentFile.branch(), getFile(referenceFile));
+  }
+
+  protected String getFile(String referencedFile) {
+    if (referencedFile.isEmpty()) { // Implies a task from root task.config
+      return TaskFileConstants.TASK_CFG;
+    }
+
+    if (referencedFile.startsWith("/")) { // Implies absolute path to the config is provided
+      return Paths.get(TaskFileConstants.TASK_DIR, referencedFile).toString();
+    }
+
+    // Implies a relative path to sub-directory
+    Path dir = Paths.get(currentFile.file()).getParent();
+    if (dir == null) { // Relative path in root task.config should refer to files under task dir
+      return Paths.get(TaskFileConstants.TASK_DIR, referencedFile).toString();
+    } else {
+      return Paths.get(dir.toString(), referencedFile).toString();
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/task.md b/src/main/resources/Documentation/task.md
index 85fd25a..8a75c0e 100644
--- a/src/main/resources/Documentation/task.md
+++ b/src/main/resources/Documentation/task.md
@@ -166,8 +166,8 @@
 from the current task if they redefined in the current task. Attributes
 which are lists (such as subtasks) or maps (such as properties), will be
 preloaded by the preload-task and then extended with the attributes from the
-current task. See [Optional Tasks](#optional_tasks) for how to define optional
-preload-tasks.
+current task. See [Task Expression](task_expression.html) for how to define
+optional preload-tasks.
 
 Example:
 ```
@@ -178,8 +178,8 @@
 
 : This key lists the name of a subtask of the current task. This key may be
 used several times in a task section to define more than one subtask for a
-particular task. See [Optional Tasks](#optional_tasks) for how to define
-optional subtasks.
+particular task. See [Task Expression](task_expression.html) for how to define
+subtasks.
 
 Example:
 
@@ -323,27 +323,6 @@
     fail = label:code-review-2
 ```
 
-<a id="optional_tasks"/>
-Optional Tasks
---------------
-To define a task that may not exist and that will not cause the task referencing
-it to be INVALID, follow the task name with pipe (`|`) character. This feature
-is particularly useful when a property is used in the task name.
-
-```
-    preload-task = Optional Subtask {$_name} |
-```
-
-To define an alternate task to load when an optional task does not exist,
-list the alterante task name after the pipe (`|`) character. This feature
-may be chained together as many times as needed.
-
-```
-    subtask = Optional Subtask {$_name} |
-              Backup Optional Subtask {$_name} Backup |
-              Default Subtask # Must exist if the above two don't!
-```
-
 <a id="tasks_factory"/>
 Tasks-Factory
 -------------
diff --git a/src/main/resources/Documentation/task_expression.md b/src/main/resources/Documentation/task_expression.md
new file mode 100644
index 0000000..f9febcf
--- /dev/null
+++ b/src/main/resources/Documentation/task_expression.md
@@ -0,0 +1,115 @@
+<a id="task_expression"/>
+Task Expression
+--------------
+
+The tasks in subtask and preload-task can be defined using a Task Expression.
+Each task expression can contain multiple tasks (all can be optional). Tasks
+from other files can be referenced using [Task Reference](#task_reference).
+
+```
+TASK_EXPR = TASK_REFERENCE [ WHITE_SPACE * '|' [ WHITE_SPACE * TASK_EXPR ] ]
+```
+
+To define a task that may not exist and that will not cause the task referencing
+it to be INVALID, follow the task name with pipe (`|`) character. This feature
+is particularly useful when a property is used in the task name.
+
+```
+    preload-task = Optional task {$_name} |
+```
+
+To define an alternate task to load when an optional task does not exist,
+list the alternate task name after the pipe (`|`) character. This feature
+may be chained together as many times as needed.
+
+```
+    subtask = Optional Subtask {$_name} |
+              Backup Optional Subtask {$_name} Backup |
+              Default Subtask # Must exist if the above two don't!
+```
+
+<a id="task_reference"/>
+Task Reference
+---------
+
+Tasks reference can be a simple task name when the defined task is intended to be in
+the same file, tasks from other files can also be referenced by syntax explained below.
+
+```
+TASK_REFERENCE = [ [ TASK_FILE_PATH ] '^' ] TASK_NAME
+```
+
+To reference a task from root task.config (top level task.config file of a repository)
+on the current ref, prefix the task name with `^`.
+
+Example:
+
+task/.../<any>.config
+```
+    ...
+    preload-task = ^Task in root task config
+    ...
+```
+
+task.config
+```
+    ...
+    [task "Task in root task config"]
+    ...
+```
+
+To provide an absolute reference to a task under the `task` folder, provide the subpath starting
+from `task` directory with a leading `/` followed by a `^` and then task name.
+
+Example:
+
+task.config
+```
+    ...
+    subtask =  /foo/bar/baz.config^Absolute Example Task
+    ...
+```
+
+task/foo/bar/baz.config
+```
+    ...
+    [task "Absolute Example Task"]
+    ...
+```
+
+Similarly, to provide reference to tasks which are in a subdirectory of the file containing the
+current task avoid the leading `/`.
+
+Example:
+
+task/foo/file.config
+```
+    ...
+    subtask = bar/baz.config^Relative Example Task
+    ...
+```
+
+task/foo/bar/baz.config
+```
+    ...
+    [task "Relative Example Task"]
+    ...
+```
+
+Relative tasks specified in a root task.config would look for a file path under the task directory.
+
+Example:
+
+task.config
+```
+    ...
+    subtask = foo/bar.config^Relative from Root Example Task
+    ...
+```
+
+task/foo/bar.config
+```
+    ...
+    [task "Relative from Root Example Task"]
+    ...
+```
diff --git a/src/main/resources/Documentation/test/task-preview/non_root_with_subtask_from_root_task.md b/src/main/resources/Documentation/test/task-preview/non_root_with_subtask_from_root_task.md
new file mode 100644
index 0000000..a0f11eb
--- /dev/null
+++ b/src/main/resources/Documentation/test/task-preview/non_root_with_subtask_from_root_task.md
@@ -0,0 +1,47 @@
+# --task-preview non-root file with subtask pointing root task
+
+file: `All-Projects.git:refs/meta/config:task.config`
+```
+[root "Points to subFile task with rootFile task preview"]
+    applicable = is:open
+    pass = True
+    subtask = foo/bar/baz.config^Preview pointing to rootFile task
+
+[task "Task in rootFile"]
+    applicable = is:open
+    pass = True
+```
+
+file: `All-Projects.git:refs/meta/config:task/foo/bar/baz.config`
+```
+ [task "Preview pointing to rootFile task"]
+     applicable = is:open
+     pass = Fail
++    subtask = ^Task in rootFile
+```
+
+json:
+```
+{
+   "applicable" : true,
+   "hasPass" : true,
+   "name" : "Points to subFile task with rootFile task preview",
+   "status" : "WAITING",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : true,
+         "name" : "Preview pointing to rootFile task",
+         "status" : "READY",
+         "subTasks" : [
+            {
+               "applicable" : true,
+               "hasPass" : true,
+               "name" : "Task in rootFile",
+               "status" : "PASS"
+            }
+         ]
+      }
+   ]
+}
+```
\ No newline at end of file
diff --git a/src/main/resources/Documentation/test/task_states.md b/src/main/resources/Documentation/test/task_states.md
index ef8285f..0b8debb 100644
--- a/src/main/resources/Documentation/test/task_states.md
+++ b/src/main/resources/Documentation/test/task_states.md
@@ -2155,6 +2155,123 @@
    ]
 }
 
+[root "Root Import tasks using absolute syntax"]
+  applicable = is:open
+  subtask = /relative.config^Root Import task from subdir using relative syntax
+  subtask = /dir/common.config^Root Import task from root task.config
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Import tasks using absolute syntax",
+   "status" : "PASS",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Root Import task from subdir using relative syntax",
+         "status" : "PASS",
+         "subTasks" : [
+            {
+                "applicable" : true,
+                "hasPass" : true,
+                "name" : "Sample relative task in sub dir",
+                "status" : "PASS"
+            }
+         ]
+      },
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Root Import task from root task.config",
+         "status" : "PASS",
+         "subTasks" : [
+             {
+                 "applicable" : true,
+                 "hasPass" : true,
+                 "name" : "Subtask PASS",
+                 "status" : "PASS"
+             }
+         ]
+      }
+   ]
+}
+
+[root "Root Import relative tasks from root config"]
+  applicable = is:open
+  subtask = dir/common.config^Root Import task from root task.config
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Import relative tasks from root config",
+   "status" : "PASS",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "name" : "Root Import task from root task.config",
+         "status" : "PASS",
+         "subTasks" : [
+             {
+                 "applicable" : true,
+                 "hasPass" : true,
+                 "name" : "Subtask PASS",
+                 "status" : "PASS"
+             }
+         ]
+      }
+   ]
+}
+
+[root "Root subtasks-external user ref with Absolute and Relative syntaxes"]
+  subtasks-external = user absolute and relative syntaxes
+
+[external "user absolute and relative syntaxes"]
+  user = testuser
+  file = dir/sample.config
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root subtasks-external user ref with Absolute and Relative syntaxes",
+   "status" : "PASS",
+   "subTasks" : [
+       {
+           "applicable" : true,
+           "hasPass" : true,
+           "name" : "Referencing single task from same user ref",
+           "status" : "PASS",
+           "subTasks" : [
+               {
+                   "applicable" : true,
+                   "hasPass" : true,
+                   "name" : "Relative Task",
+                   "status" : "PASS"
+               },
+               {
+                   "applicable" : true,
+                   "hasPass" : true,
+                   "name" : "Relative Task in sub dir",
+                   "status" : "PASS"
+               },
+               {
+                   "applicable" : true,
+                   "hasPass" : true,
+                   "name" : "task in user root config file",
+                   "status" : "PASS"
+               },
+               {
+                   "applicable" : true,
+                   "hasPass" : true,
+                   "name" : "Absolute Task",
+                   "status" : "PASS"
+               }
+           ]
+       }
+   ]
+}
+
 [root "Root INVALID Preload"]
   preload-task = missing
 
@@ -2745,6 +2862,23 @@
   fail = is:open
 ```
 
+file: `All-Projects:refs/meta/config:task/relative.config`
+```
+[task "Root Import task from subdir using relative syntax"]
+    subtask = dir/common.config^Sample relative task in sub dir
+```
+
+file: `All-Projects:refs/meta/config:task/dir/common.config`
+```
+[task "Sample relative task in sub dir"]
+    applicable = is:open
+    pass = is:open
+
+[task "Root Import task from root task.config"]
+    applicable = is:open
+    subtask = ^Subtask PASS
+```
+
 file: `All-Projects:refs/meta/config:task/invalids.config`
 ```
 [task "No PASS criteria"]
@@ -2930,3 +3064,42 @@
   applicable = is:open
   fail = is:open
 ```
+
+file: `All-Users:refs/users/self:task.config`
+```
+[task "task in user root config file"]
+  applicable = is:open
+  pass = is:open
+```
+
+file: `All-Users:refs/users/self:task/dir/sample.config`
+```
+[task "Referencing single task from same user ref"]
+  applicable = is:open
+  pass = is:open
+  subtask = relative.config^Relative Task
+  subtask = sub_dir/relative.config^Relative Task in sub dir
+  subtask = ^task in user root config file
+  subtask = /foo/bar.config^Absolute Task
+```
+
+file: `All-Users:refs/users/self:task/dir/relative.config`
+```
+[task "Relative Task"]
+  applicable = is:open
+  pass = is:open
+```
+
+file: `All-Users:refs/users/self:task/dir/sub_dir/relative.config`
+```
+[task "Relative Task in sub dir"]
+  applicable = is:open
+  pass = is:open
+```
+
+file: `All-Users:refs/users/self:task/foo/bar.config`
+```
+[task "Absolute Task"]
+  applicable = is:open
+  pass = is:open
+```
diff --git a/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java b/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java
index 2f27439..90b3dc3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/task/TaskExpressionTest.java
@@ -22,10 +22,13 @@
 
 /*
  * <ul>
- *   <li><code> "simple"        -> ("simple")         required</code>
- *   <li><code> "world | peace" -> ("world", "peace") required</code>
- *   <li><code> "shadenfreud |" -> ("shadenfreud")    optional</code>
- *   <li><code> "foo | bar |"   -> ("foo", "bar")     optional</code>
+ *   <li><code> "simple"            -> ("simple")                   required</code>
+ *   <li><code> "world | peace"     -> ("world", "peace")           required</code>
+ *   <li><code> "shadenfreud |"     -> ("shadenfreud")              optional</code>
+ *   <li><code> "foo | bar |"       -> ("foo", "bar")               optional</code>
+ *   <li><code> "/foo^bar | baz |"  -> ("task/foo^bar", "baz")      optional</code>
+ *   <li><code> "foo^bar | baz |"   -> ("cur_dir/foo^bar", "baz")   optional</code>
+ *   <li><code> "^bar | baz |"      -> ("task.config^bar", "baz")   optional</code>
  * </ul>
  */
 public class TaskExpressionTest extends TestCase {
@@ -38,6 +41,14 @@
   public static TaskKey WORLD_TASK = TaskKey.create(file, WORLD);
   public static TaskKey PEACE_TASK = TaskKey.create(file, PEACE);
 
+  public static String SAMPLE = "sample";
+  public static String TASK_CFG = "task.config";
+  public static String SIMPLE_CFG = "task/simple.config";
+  public static String PEACE_CFG = "task/peace.config";
+  public static String WORLD_PEACE_CFG = "task/world/peace.config";
+  public static String REL_WORLD_PEACE_CFG = "world/peace.config";
+  public static String ABS_PEACE_CFG = "/peace.config";
+
   public void testBlank() {
     TaskExpression exp = getTaskExpression("");
     Iterator<TaskKey> it = exp.iterator();
@@ -143,6 +154,42 @@
     assertNoSuchElementException(it);
   }
 
+  public void testAbsoluteAndRelativeReference() {
+    TaskExpression exp =
+        getTaskExpression(
+            createFileKey(SIMPLE_CFG),
+            REL_WORLD_PEACE_CFG + "^" + SAMPLE + " | " + ABS_PEACE_CFG + "^" + SAMPLE);
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), TaskKey.create(createFileKey(WORLD_PEACE_CFG), SAMPLE));
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), TaskKey.create(createFileKey(PEACE_CFG), SAMPLE));
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testAbsoluteAndRelativeReferenceFromRoot() {
+    TaskExpression exp =
+        getTaskExpression(
+            createFileKey(TASK_CFG),
+            REL_WORLD_PEACE_CFG + "^" + SAMPLE + " | " + ABS_PEACE_CFG + "^" + SAMPLE);
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), TaskKey.create(createFileKey(WORLD_PEACE_CFG), SAMPLE));
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), TaskKey.create(createFileKey(PEACE_CFG), SAMPLE));
+    assertTrue(it.hasNext());
+    assertNoSuchElementException(it);
+  }
+
+  public void testReferenceFromRoot() {
+    TaskExpression exp = getTaskExpression(createFileKey(SIMPLE_CFG), " ^" + SAMPLE + " | ");
+    Iterator<TaskKey> it = exp.iterator();
+    assertTrue(it.hasNext());
+    assertEquals(it.next(), TaskKey.create(createFileKey(TASK_CFG), SAMPLE));
+    assertNoSuchElementException(it);
+  }
+
   public void testDifferentKeyOnDifferentFile() {
     TaskExpression exp = getTaskExpression(createFileKey("foo", "bar", "baz"), SIMPLE);
     TaskExpression otherExp = getTaskExpression(createFileKey("foo", "bar", "other"), SIMPLE);
@@ -184,6 +231,10 @@
     return new TaskExpression(file, expression);
   }
 
+  protected static FileKey createFileKey(String file) {
+    return createFileKey("foo", "bar", file);
+  }
+
   protected static FileKey createFileKey(String project, String branch, String file) {
     return FileKey.create(BranchNameKey.create(Project.NameKey.parse(project), branch), file);
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/task/TaskReferenceTest.java b/src/test/java/com/googlesource/gerrit/plugins/task/TaskReferenceTest.java
new file mode 100644
index 0000000..8d079ff
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/task/TaskReferenceTest.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.task;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import java.util.NoSuchElementException;
+import junit.framework.TestCase;
+import org.junit.Test;
+
+public class TaskReferenceTest extends TestCase {
+  public static String SIMPLE = "simple";
+  public static String ROOT = "task.config";
+  public static String COMMON = "task/common.config";
+  public static String SUB_COMMON = "task/dir/common.config";
+  public static FileKey ROOT_CFG = createFileKey("project", "branch", ROOT);
+  public static FileKey COMMON_CFG = createFileKey("project", "branch", COMMON);
+  public static FileKey SUB_COMMON_CFG = createFileKey("project", "branch", SUB_COMMON);
+
+  @Test
+  public void testReferencingTaskFromSameFile() {
+    assertEquals(createTaskKey(ROOT_CFG, SIMPLE), getTaskFromReference(ROOT_CFG, SIMPLE));
+  }
+
+  @Test
+  public void testReferencingTaskFromRootConfig() {
+    String reference = "^" + SIMPLE;
+    assertEquals(createTaskKey(ROOT_CFG, SIMPLE), getTaskFromReference(SUB_COMMON_CFG, reference));
+  }
+
+  @Test
+  public void testReferencingRelativeTaskFromRootConfig() {
+    String reference = " dir/common.config^" + SIMPLE;
+    assertEquals(createTaskKey(SUB_COMMON_CFG, SIMPLE), getTaskFromReference(ROOT_CFG, reference));
+  }
+
+  @Test
+  public void testReferencingAbsoluteTaskFromRootConfig() {
+    String reference = " /common.config^" + SIMPLE;
+    assertEquals(createTaskKey(COMMON_CFG, SIMPLE), getTaskFromReference(ROOT_CFG, reference));
+  }
+
+  @Test
+  public void testReferencingRelativeTask() {
+    String reference = " dir/common.config^" + SIMPLE;
+    assertEquals(
+        createTaskKey(SUB_COMMON_CFG, SIMPLE), getTaskFromReference(COMMON_CFG, reference));
+  }
+
+  @Test
+  public void testReferencingAbsoluteTask() {
+    String reference = " /common.config^" + SIMPLE;
+    assertEquals(
+        createTaskKey(COMMON_CFG, SIMPLE), getTaskFromReference(SUB_COMMON_CFG, reference));
+  }
+
+  @Test
+  public void testMultipleUpchars() {
+    String reference = " ^ /common.config^" + SIMPLE;
+    assertNoSuchElementException(() -> getTaskFromReference(SUB_COMMON_CFG, reference));
+  }
+
+  @Test
+  public void testEmptyReference() {
+    String empty = "";
+    assertNoSuchElementException(() -> getTaskFromReference(SUB_COMMON_CFG, empty));
+  }
+
+  protected static TaskKey getTaskFromReference(FileKey file, String expression) {
+    return new TaskReference(file, expression).getTaskKey();
+  }
+
+  protected static TaskKey createTaskKey(FileKey file, String task) {
+    return TaskKey.create(file, task);
+  }
+
+  protected static FileKey createFileKey(String project, String branch, String file) {
+    return FileKey.create(BranchNameKey.create(Project.NameKey.parse(project), branch), file);
+  }
+
+  protected static void assertNoSuchElementException(Runnable f) {
+    try {
+      f.run();
+      assertTrue(false);
+    } catch (NoSuchElementException e) {
+      assertTrue(true);
+    }
+  }
+}
diff --git a/test/check_task_visibility.sh b/test/check_task_visibility.sh
index c5855e2..1be8b96 100755
--- a/test/check_task_visibility.sh
+++ b/test/check_task_visibility.sh
@@ -199,7 +199,9 @@
 "new_root_with_original_with_external_secret_ref.md"
 "non-secret_ref_with_external_secret_ref.md"
 "root_with_external_non-secret_ref_with_external_secret_ref.md"
-"root_with_external_secret_ref.md")
+"root_with_external_secret_ref.md"
+"non_root_with_subtask_from_root_task.md"
+)
 
 for test in "${TESTS[@]}" ; do
     TEST_DOC="$(replace_user_refs < "$TEST_DOC_DIR/$test" | replace_users)"