Allow tasks to define a duplicate key to identify more duplicates

This allows more taks to be viewed as duplicates at the task
configurators discretion. This can be useful when defining
the key = ${_change_number} to prevent looping on tasks
which preload the same task. This also allows similar "end"
tasks to be defined to behave as if they had looped.

Change-Id: Id9f0311ca3709bd875e2017a4af42938ae83fded
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 698b33a..47283a3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
@@ -55,6 +55,7 @@
 
   public class TaskBase extends SubSection {
     public String applicable;
+    public String duplicateKey;
     public Map<String, String> exported;
     public String fail;
     public String failHint;
@@ -76,6 +77,7 @@
       this.isVisible = isVisible;
       this.isTrusted = isTrusted;
       applicable = getString(s, KEY_APPLICABLE, null);
+      duplicateKey = getString(s, KEY_DUPLICATE_KEY, null);
       exported = getProperties(s, KEY_EXPORT_PREFIX);
       fail = getString(s, KEY_FAIL, null);
       failHint = getString(s, KEY_FAIL_HINT, null);
@@ -194,6 +196,7 @@
   protected static final String SECTION_TASKS_FACTORY = "tasks-factory";
   protected static final String KEY_APPLICABLE = "applicable";
   protected static final String KEY_CHANGES = "changes";
+  protected static final String KEY_DUPLICATE_KEY = "duplicate-key";
   protected static final String KEY_EXPORT_PREFIX = "export-";
   protected static final String KEY_FAIL = "fail";
   protected static final String KEY_FAIL_HINT = "fail-hint";
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 d08a68e..5461d43 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskTree.java
@@ -104,12 +104,14 @@
       throws ConfigInvalidException, IOException, OrmException {
     this.changeData = changeData;
     root.path = Collections.emptyList();
+    root.duplicateKeys = Collections.emptyList();
     return root.getSubNodes();
   }
 
   protected class NodeList {
     protected NodeList parent = null;
     protected Collection<String> path;
+    protected Collection<String> duplicateKeys;
     protected Map<TaskKey, Node> cachedNodeByTask = new HashMap<>();
     protected List<Node> nodes;
     protected Set<String> names = new HashSet<>();
@@ -214,6 +216,12 @@
 
       this.task = properties.getTask(getChangeData());
 
+      this.duplicateKeys = new LinkedList<>(parent.duplicateKeys);
+      if (task.duplicateKey != null) {
+        isDuplicate |= duplicateKeys.contains(task.duplicateKey);
+        duplicateKeys.add(task.duplicateKey);
+      }
+
       if (nodes != null && properties.isSubNodeReloadRequired()) {
         cachedNodeByTask.clear();
         nodes.stream().filter(n -> n != null).forEach(n -> cachedNodeByTask.put(n.task.key(), n));
diff --git a/src/main/resources/Documentation/task.md b/src/main/resources/Documentation/task.md
index 3f2166e..ce2dd40 100644
--- a/src/main/resources/Documentation/task.md
+++ b/src/main/resources/Documentation/task.md
@@ -237,6 +237,48 @@
     subtasks-file = common.config  # references the file named task/common.config
 ```
 
+`duplicate-key`
+
+: This key defines an identifier to help identify tasks which should be
+considered duplicates even if they are not exact duplicates. When the task
+plugin encounters a task with the same duplicate-key as one of its
+ancestors, it will be considered a duplicate of that ancestor. Tasks such as
+a starting task and a looping tasks-factory that preload the same base task
+are not exact duplicates, yet they may logically represent duplicates. In
+this case, defining a `duplicate-key` on the base task which is preloaded
+from two different places (usually a root and a change tasks-factory), will
+ensure that any loops are halted once the original change is reached. Without
+a duplicate-key, the walking would generally walk one task further than
+desired.
+
+Outlined below is a simple way to walk a change's git dependencies in the
+task plugin. While Git does not allow loops in commit histories, sometimes
+in Gerrit when changes get rebased, it can cause loops (because Gerrit
+sometimes tracks outdated dependencies). The use of the duplicate-key
+below results in the loop being detected when you would expect it to be.
+
+Example:
+
+```
+[root "git dependencies"]
+    applicable = status:new
+    preload-task = git dependencies
+
+[task "git dependencies"]
+    fail = -status:new
+    fail-hint = [${_change_status}] dependency needs to be OPEN
+    subtasks-factory = git dependencies
+    duplicate-key = git dependencies ${_change_number}
+
+[tasks-factory "git dependencies"]
+    names-factory = git dependencies
+    preload-task = git dependencies
+
+[names-factory "git dependencies"]
+    type = change
+    changes = -status:merged parentof:${_change_number} project:${_change_project} branch:${_change_branch}
+```
+
 Root Tasks
 ----------
 Root tasks typically define the "final verification" tasks for changes. Each
diff --git a/src/main/resources/Documentation/test/task_states.md b/src/main/resources/Documentation/test/task_states.md
index 3b5d034..fede191 100644
--- a/src/main/resources/Documentation/test/task_states.md
+++ b/src/main/resources/Documentation/test/task_states.md
@@ -1845,6 +1845,34 @@
    ]
 }
 
+[root "Root Looping DuplicateKey"]
+  preload-task = DuplicateKey
+
+[task "Looping DuplicateKey"]
+  preload-task = DuplicateKey
+  pass = True
+
+[task "DuplicateKey"]
+  duplicate-key = 1234
+  subtask = Looping DuplicateKey
+
+
+{
+   "applicable" : true,
+   "hasPass" : false,
+   "name" : "Root Looping DuplicateKey",
+   "status" : "PASS",
+   "subTasks" : [
+      {
+         "applicable" : true,
+         "hasPass" : false,
+         "hint" : "Duplicate task is non blocking and empty to break the loop",
+         "name" : "Looping DuplicateKey",
+         "status" : "DUPLICATE"
+      }
+   ]
+}
+
 [root "Root changes loop"]
   subtask = task (tasks-factory changes loop)