Add a --task--preview flag to the query command

This new flag takes arguments for a patchset. Tasks will then be
evaluated as if the patchset were already merged. Multiple changes
may be previewed together.

Change-Id: I87fae8a1bbb91706d170f2fda69b8cfbef84533b
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
index 061acb7..e50a308 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/Modules.java
@@ -20,6 +20,10 @@
 import com.google.gerrit.server.restapi.change.QueryChanges;
 import com.google.gerrit.sshd.commands.Query;
 import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.task.cli.PatchSetArgument;
+import java.util.ArrayList;
+import java.util.List;
 import org.kohsuke.args4j.Option;
 
 public class Modules {
@@ -52,5 +56,23 @@
 
     @Option(name = "--applicable", usage = "Include only applicable tasks in the output")
     public boolean onlyApplicable = false;
+
+    @Option(
+        name = "--preview",
+        metaVar = "{CHANGE,PATCHSET}",
+        usage = "list of patch sets to preview task evaluation for")
+    public void addPatchSet(String token) {
+      PatchSetArgument psa = patchSetArgumentFactory.createForArgument(token);
+      patchSetArguments.add(psa);
+    }
+
+    public List<PatchSetArgument> patchSetArguments = new ArrayList<>();
+
+    public PatchSetArgument.Factory patchSetArgumentFactory;
+
+    @Inject
+    public MyOptions(PatchSetArgument.Factory patchSetArgumentFactory) {
+      this.patchSetArgumentFactory = patchSetArgumentFactory;
+    }
   }
 }
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 a4af8b5..b55f861 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskAttributeFactory.java
@@ -30,6 +30,7 @@
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.task.TaskConfig.External;
 import com.googlesource.gerrit.plugins.task.TaskConfig.Task;
+import com.googlesource.gerrit.plugins.task.cli.PatchSetArgument;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -94,6 +95,9 @@
   public PluginDefinedInfo create(ChangeData c, ChangeQueryProcessor qp, String plugin) {
     options = (Modules.MyOptions) qp.getDynamicBean(plugin);
     if (options.all || options.onlyApplicable) {
+      for (PatchSetArgument psa : options.patchSetArguments) {
+        taskFactory.masquerade(psa);
+      }
       try {
         return createWithExceptions(c);
       } catch (OrmException e) {
@@ -137,9 +141,11 @@
       throws OrmException {
     try {
       boolean applicable = match(c, def.applicable);
-      if (!applicable && !def.isVisible && !options.onlyApplicable) {
-        tasks.add(unknown());
-        return;
+      if (!def.isVisible) {
+        if (!def.isTrusted || (!applicable && !options.onlyApplicable)) {
+          tasks.add(unknown());
+          return;
+        }
       }
 
       if (applicable || !options.onlyApplicable) {
@@ -171,7 +177,8 @@
     List<TaskAttribute> subTasks = new ArrayList<>();
     for (String file : parent.subTasksFiles) {
       try {
-        tasks.addAll(getTasks(parent.config.getBranch(), resolveTaskFileName(file)));
+        tasks.addAll(
+            getTasks(parent.config.getBranch(), resolveTaskFileName(file), parent.isTrusted));
       } catch (ConfigInvalidException | IOException e) {
         subTasks.add(invalid());
       }
@@ -182,7 +189,7 @@
         if (ext == null) {
           subTasks.add(invalid());
         } else {
-          tasks.addAll(getTasks(ext));
+          tasks.addAll(getTasks(ext, parent.isTrusted));
         }
       } catch (ConfigInvalidException | IOException e) {
         subTasks.add(invalid());
@@ -225,14 +232,15 @@
     return tasks;
   }
 
-  protected List<Task> getTasks(External external)
+  protected List<Task> getTasks(External external, boolean isTrusted)
       throws ConfigInvalidException, IOException, OrmException {
-    return getTasks(resolveUserBranch(external.user), resolveTaskFileName(external.file));
+    return getTasks(
+        resolveUserBranch(external.user), resolveTaskFileName(external.file), isTrusted);
   }
 
-  protected List<Task> getTasks(Branch.NameKey branch, String file)
+  protected List<Task> getTasks(Branch.NameKey branch, String file, boolean isTrusted)
       throws ConfigInvalidException, IOException {
-    return taskFactory.getTaskConfig(branch, file).getTasks();
+    return taskFactory.getTaskConfig(branch, file, isTrusted).getTasks();
   }
 
   protected String resolveTaskFileName(String file) throws ConfigInvalidException {
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 204a636..64add65 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfig.java
@@ -44,9 +44,11 @@
     public List<String> subTasksFiles;
 
     public boolean isVisible;
+    public boolean isTrusted;
 
-    public Task(SubSection s, boolean isVisible) {
+    public Task(SubSection s, boolean isVisible, boolean isTrusted) {
       this.isVisible = isVisible;
+      this.isTrusted = isTrusted;
       applicable = getString(s, KEY_APPLICABLE, null);
       fail = getString(s, KEY_FAIL, null);
       failHint = getString(s, KEY_FAIL_HINT, null);
@@ -89,10 +91,12 @@
   protected static final String KEY_USER = "user";
 
   public boolean isVisible;
+  public boolean isTrusted;
 
-  public TaskConfig(Branch.NameKey branch, String fileName, boolean isVisible) {
+  public TaskConfig(Branch.NameKey branch, String fileName, boolean isVisible, boolean isTrusted) {
     super(branch, fileName);
     this.isVisible = isVisible;
+    this.isTrusted = isTrusted;
   }
 
   public List<Task> getRootTasks() {
@@ -107,7 +111,7 @@
     List<Task> tasks = new ArrayList<>();
     // No need to get a task with no name (what would we call it?)
     for (String task : cfg.getSubsections(type)) {
-      tasks.add(new Task(new SubSection(type, task), isVisible));
+      tasks.add(new Task(new SubSection(type, task), isVisible, isTrusted));
     }
     return tasks;
   }
@@ -122,7 +126,7 @@
   }
 
   public Task getTask(String name) {
-    return new Task(new SubSection(SECTION_TASK, name), isVisible);
+    return new Task(new SubSection(SECTION_TASK, name), isVisible, isTrusted);
   }
 
   public External getExternal(String name) {
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 1d9be90..418df6e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/TaskConfigFactory.java
@@ -24,7 +24,10 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.task.cli.PatchSetArgument;
 import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
@@ -42,6 +45,8 @@
   protected final CurrentUser user;
   protected final AllProjectsName allProjects;
 
+  protected final Map<Branch.NameKey, PatchSetArgument> psaMasquerades = new HashMap<>();
+
   @Inject
   protected TaskConfigFactory(
       AllProjectsName allProjects,
@@ -55,17 +60,30 @@
   }
 
   public TaskConfig getRootConfig() throws ConfigInvalidException, IOException {
-    return getTaskConfig(getRootBranch(), DEFAULT);
+    return getTaskConfig(getRootBranch(), DEFAULT, true);
+  }
+
+  public void masquerade(PatchSetArgument psa) {
+    psaMasquerades.put(psa.change.getDest(), psa);
   }
 
   protected Branch.NameKey getRootBranch() {
     return new Branch.NameKey(allProjects, "refs/meta/config");
   }
 
-  public TaskConfig getTaskConfig(Branch.NameKey branch, String fileName)
+  public TaskConfig getTaskConfig(Branch.NameKey branch, String fileName, boolean isTrusted)
       throws ConfigInvalidException, IOException {
-    TaskConfig cfg = new TaskConfig(branch, fileName, canRead(branch));
+    PatchSetArgument psa = psaMasquerades.get(branch);
+    boolean visible = true; // invisible psas are filtered out by commandline
+    if (psa == null) {
+      visible = canRead(branch);
+    } else {
+      isTrusted = false;
+      branch = new Branch.NameKey(psa.change.getProject(), psa.patchSet.getId().toRefName());
+    }
+
     Project.NameKey project = branch.getParentKey();
+    TaskConfig cfg = new TaskConfig(branch, fileName, visible, isTrusted);
     try (Repository git = gitMgr.openRepository(project)) {
       cfg.load(project, git);
     } catch (IOException e) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/task/cli/PatchSetArgument.java b/src/main/java/com/googlesource/gerrit/plugins/task/cli/PatchSetArgument.java
new file mode 100644
index 0000000..a7e9063
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/task/cli/PatchSetArgument.java
@@ -0,0 +1,106 @@
+// 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.
+package com.googlesource.gerrit.plugins.task.cli;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+public class PatchSetArgument {
+  public static class Factory {
+    protected final PermissionBackend permissionBackend;
+    protected final ChangeNotes.Factory notesFactory;
+    protected final ReviewDb reviewDb;
+    protected final PatchSetUtil psUtil;
+    protected final CurrentUser user;
+
+    @Inject
+    protected Factory(
+        ChangeNotes.Factory notesFactory,
+        PermissionBackend permissionBackend,
+        ReviewDb reviewDb,
+        PatchSetUtil psUtil,
+        CurrentUser user) {
+      this.notesFactory = notesFactory;
+      this.permissionBackend = permissionBackend;
+      this.reviewDb = reviewDb;
+      this.psUtil = psUtil;
+      this.user = user;
+    }
+
+    public PatchSetArgument createForArgument(String token) {
+      try {
+        PatchSet.Id patchSetId = parsePatchSet(token);
+        ChangeNotes changeNotes = notesFactory.createChecked(patchSetId.getParentKey());
+        permissionBackend
+            .user(user)
+            .database(reviewDb)
+            .change(changeNotes)
+            .check(ChangePermission.READ);
+        return new PatchSetArgument(
+            changeNotes.getChange(), psUtil.get(reviewDb, changeNotes, patchSetId));
+      } catch (PermissionBackendException | AuthException e) {
+        throw new IllegalArgumentException("database error", e);
+      } catch (UnloggedFailure e) {
+        throw new IllegalArgumentException(e.getMessage(), e);
+      } catch (OrmException e) {
+        throw new IllegalArgumentException("database error", e);
+      }
+    }
+
+    protected PatchSet.Id parsePatchSet(String patchIdentity) throws UnloggedFailure, OrmException {
+      // By older style change,patchset
+      if (patchIdentity.matches("^[1-9][0-9]*,[1-9][0-9]*$")) {
+        try {
+          return PatchSet.Id.parse(patchIdentity);
+        } catch (IllegalArgumentException e) {
+          throw error("\"" + patchIdentity + "\" is not a valid patch set");
+        }
+      }
+      throw error("\"" + patchIdentity + "\" is not a valid patch set");
+    }
+
+    protected String noSuchPatchSet(String patchIdentity) {
+      return "\"" + patchIdentity + "\" no such patch set";
+    }
+
+    protected static UnloggedFailure error(final String msg) {
+      return new UnloggedFailure(1, msg);
+    }
+  }
+
+  public final PatchSet patchSet;
+  public final Change change;
+
+  public PatchSetArgument(Change change, PatchSet patchSet) {
+    this.patchSet = patchSet;
+    this.change = change;
+  }
+
+  public void ensureLatest() {
+    if (!change.currentPatchSetId().equals(patchSet.getId())) {
+      throw new IllegalArgumentException(patchSet + " is not the latest patch set");
+    }
+  }
+}
diff --git a/src/main/resources/Documentation/task.md b/src/main/resources/Documentation/task.md
index e4c2abf..13095d8 100644
--- a/src/main/resources/Documentation/task.md
+++ b/src/main/resources/Documentation/task.md
@@ -265,7 +265,7 @@
 Change Query Output
 -------------------
 It is possible to add a task section to the query output of changes using
-the task plugin switches.  The following switches are available:
+the task plugin switches. The following switches are available:
 
 **\-\-@PLUGIN@\-\-applicable**
 
@@ -275,10 +275,17 @@
 **\-\-@PLUGIN@\-\-all**
 
 This switch is meant as a debug switch, it outputs all tasks visible to the
-calling user, whether they apply to a change or not.  When this flag is used,
+calling user, whether they apply to a change or not. When this flag is used,
 an additional 'applicable' property is included in each task output to
 indicate whether the task actually met its applicability criteria or not.
 
+**\-\-@PLUGIN@\-\-preview**
+
+This switch is meant as a debug switch for previewing changes to task configs.
+This switch outputs tasks as if the supplied change were already merged. This
+makes it possible to preview the effect of proposed changes before going live
+with them. Multiple changes may be previewed together.
+
 When tasks are appended to changes, they will have a "task" section under
 the plugins section like below:
 
diff --git a/src/main/resources/Documentation/task_states.md b/src/main/resources/Documentation/task_states.md
index 953693a..f417b6d 100644
--- a/src/main/resources/Documentation/task_states.md
+++ b/src/main/resources/Documentation/task_states.md
@@ -139,7 +139,7 @@
   applicable = NOT is:open
 
 [external "user special"]
-  user = current-user
+  user = testuser
   file = special.config
 
 [external "user missing"]
@@ -147,7 +147,7 @@
   file = special.config
 
 [external "file missing"]
-  user = current-user
+  user = testuser
   file = missing
 ```
 
diff --git a/test/check_task_statuses.sh b/test/check_task_statuses.sh
index 410603e..3e90f83 100755
--- a/test/check_task_statuses.sh
+++ b/test/check_task_statuses.sh
@@ -18,13 +18,30 @@
 
 # --------
 
+q() { "$@" > /dev/null 2>&1 ; } # cmd [args...]  # quiet a command
+
 # Run a test setup command quietly, exit on failure
 q_setup() { # cmd [args...]
   local out ; out=$("$@" 2>&1) || { echo "$out" ; exit ; }
 }
 
+replace_user() { # < text_with_testuser > text_with_$USER
+    sed -e"s/testuser/$USER/"
+}
+
 example() { # example_num
-    awk '/```/{Q++;E=(Q+1)/2};E=='"$1" < "$DOC_STATES" | grep -v '```'
+    awk '/```/{Q++;E=(Q+1)/2};E=='"$1" < "$DOC_STATES" | grep -v '```' | replace_user
+}
+
+get_change_num() { # < gerrit_push_response > changenum
+    local url=$(awk '/New Changes:/ { getline; print $2 }')
+    echo "${url##*\/}" | tr -d -c '[:digit:]'
+}
+
+install_changeid_hook() { # repo
+    local hook=$(git rev-parse --git-dir)/hooks/commit-msg
+    scp -p -P "$PORT" "$SERVER":hooks/commit-msg "$hook"
+    chmod +x "$hook"
 }
 
 setup_repo() { # repo remote ref
@@ -32,6 +49,7 @@
     git init "$repo"
     (
         cd "$repo"
+        install_changeid_hook "$repo"
         git fetch "$remote" "$ref"
         git checkout FETCH_HEAD
     )
@@ -47,6 +65,16 @@
     )
 }
 
+create_repo_change() { # repo remote ref > change_num
+    local repo=$1 remote=$2 ref=$3
+    (
+        q cd "$repo"
+        q git add .
+        q git commit -m 'Testing task plugin'
+        git push "$remote" HEAD:"refs/for/$ref" 2>&1 | get_change_num
+    )
+}
+
 query() { # query
     ssh -x -p "$PORT" "$SERVER" gerrit query "$@" \
             --format json | head -1 | python -c "import sys, json; \
@@ -120,3 +148,7 @@
 query="status:open limit:1"
 test_tasks statuses "$EXPECTED" --task--applicable "$query"
 test_file all --task--all "$query"
+
+replace_user < "$MYDIR"/root.change > "$ROOT_CFG"
+cnum=$(create_repo_change "$ALL" "$REMOTE_ALL" "$REF_ALL")
+test_file preview --task--preview "$cnum,1" --task--all "$query"
diff --git a/test/preview b/test/preview
new file mode 100644
index 0000000..8effb1a
--- /dev/null
+++ b/test/preview
@@ -0,0 +1,177 @@
+   "plugins" : [
+      {
+         "name" : "task",
+         "roots" : [
+            {
+               "applicable" : true,
+               "name" : "INVALIDS",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "name" : "No PASS criteria",
+                     "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "WAITING (subtask INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "Subtask INVALID",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "WAITING (subtask missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "MISSING",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "Grouping WAITING (subtask INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "Subtask INVALID",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "Grouping WAITING (subtask missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "MISSING",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "Subtask INVALID",
+                     "status" : "INVALID"
+                  }
+               ]
+            },
+            {
+               "applicable" : true,
+               "name" : "Root PASS",
+               "status" : "PASS"
+            },
+            {
+               "applicable" : true,
+               "hint" : "You must now run the ready task",
+               "name" : "Root READY (subtask PASS)",
+               "status" : "READY",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "name" : "Subtask PASS",
+                     "status" : "PASS"
+                  }
+               ]
+            },
+            {
+               "applicable" : true,
+               "name" : "Subtasks External",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "name" : "userfile task/special.config PASS",
+                     "status" : "PASS"
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "userfile task/special.config FAIL",
+                     "status" : "FAIL"
+                  }
+               ]
+            },
+            {
+               "applicable" : false,
+               "name" : "Root NA Pass",
+               "status" : "PASS"
+            },
+            {
+               "applicable" : false,
+               "name" : "NA INVALIDS",
+               "status" : "WAITING",
+               "subTasks" : [
+                  {
+                     "applicable" : true,
+                     "name" : "No PASS criteria",
+                     "status" : "INVALID"
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "WAITING (subtask INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "Subtask INVALID",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "WAITING (subtask missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "MISSING",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "Grouping WAITING (subtask INVALID)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "Subtask INVALID",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "Grouping WAITING (subtask missing)",
+                     "status" : "WAITING",
+                     "subTasks" : [
+                        {
+                           "applicable" : true,
+                           "name" : "MISSING",
+                           "status" : "INVALID"
+                        }
+                     ]
+                  },
+                  {
+                     "applicable" : true,
+                     "name" : "Subtask INVALID",
+                     "status" : "INVALID"
+                  }
+               ]
+            }
+         ]
+      }
+   ],
diff --git a/test/root.change b/test/root.change
new file mode 100644
index 0000000..01bf55b
--- /dev/null
+++ b/test/root.change
@@ -0,0 +1,33 @@
+[root "INVALIDS"]
+  applicable = is:open
+  subtasks-file = invalids.config
+
+[root "Root PASS"]
+  applicable = is:open
+  pass = True
+
+[root "Root READY (subtask PASS)"]
+  applicable = is:open
+  pass = -is:open
+  subtask = Subtask PASS
+  ready-hint = You must now run the ready task
+
+[root "Subtasks External"]
+  applicable = is:open
+  subtasks-external = user special
+
+[root "Root NA Pass"]
+  applicable = -is:open
+  pass = True
+
+[root "NA INVALIDS"]
+  applicable = -is:open
+  subtasks-file = invalids.config
+
+[task "Subtask PASS"]
+  applicable = is:open
+  pass = is:open
+
+[external "user special"]
+  user = testuser
+  file = special.config