workflow: add invoke-issue-restapi and invoke-project-restapi actions

The invoke-issue-restapi invokes RestAPI on the issue, it uses soy
template to generate the request.

   action = invoke-issue-restapi method uri passCodes template

The invoke-project-restapi is similar but invokes project method.

These commands enables advanced integration with most of JIRA features.

For example if you would like to create a link in issues to review, use
the following action:

  action = invoke-issue-restapi POST /remotelink 200,201 link

With the follwing `its/templates/link.soy` template:

  {namespace etc.its.templates}
  {template .link}
    {@param changeUrl: string}
    {@param subject: string}
    {@param status: string}
  {lb}
    "globalId": "{$changeUrl}",
    "application": {lb}
      "type": "com.googlesource.gerrit",
      "name": "Gerrit"
    {rb},
    "object": {lb}
      "url": "{$changeUrl}",
      "title": "{$subject}",
      "icon": {lb}
        "url16x16": "https://www.gerritcodereview.com/images/diffy_logo.png",
        "title": "Review"
      {rb},
      "status": {lb}
        {switch $status}
          {case null}
            "resolved": false
          {case 'NEW'}
            "resolved": false
          {case 'SUBMITTED'}
            "resolved": false
          {case 'MERGED'}
            "resolved": true
          {case 'ABANDONED'}
            "resolved": true
        {/switch}
      {rb}
    {rb}
  {rb}
  {/template}

Change-Id: I617c110764d48ed9dd2162c5414945e0d7b9d90c
Signed-off-by: Alon Bar-Lev <alon.barlev@gmail.com>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraClient.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraClient.java
index 5544df2..7c1c35d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraClient.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraClient.java
@@ -109,6 +109,50 @@
     }
   }
 
+  public void invokeProjectRestAPI(
+      JiraItsServerInfo server,
+      String projectKey,
+      String method,
+      String uri,
+      int[] passCodes,
+      String body)
+      throws IOException {
+
+    log.debug(
+        "Trying to invoke project {} restapi method {} uri {} status {} body {}",
+        projectKey,
+        method,
+        uri,
+        Arrays.toString(passCodes),
+        body);
+    apiBuilder.getProjects(server).sendPayload(method, projectKey + uri, body, passCodes);
+    log.debug("reatapi invoked project {}", projectKey);
+  }
+
+  public void invokeIssueRestAPI(
+      JiraItsServerInfo server,
+      String issueKey,
+      String method,
+      String uri,
+      int[] passCodes,
+      String body)
+      throws IOException {
+
+    if (issueExists(server, issueKey)) {
+      log.debug(
+          "Trying to invoke issue {} restapi method {} uri {} status {} body {}",
+          issueKey,
+          method,
+          uri,
+          Arrays.toString(passCodes),
+          body);
+      apiBuilder.getIssue(server).sendPayload(method, issueKey + uri, body, passCodes);
+      log.debug("reatapi invoked issue {}", issueKey);
+    } else {
+      log.error("Issue {} does not exist or no access permission", issueKey);
+    }
+  }
+
   public void createVersion(JiraItsServerInfo server, String projectKey, String version)
       throws IOException {
     log.debug("Trying to create version {} on project {}", version, projectKey);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraModule.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraModule.java
index 3d59458..4cdda87 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraModule.java
@@ -29,6 +29,8 @@
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
 import com.googlesource.gerrit.plugins.its.base.its.ItsFacadeFactory;
 import com.googlesource.gerrit.plugins.its.base.workflow.CustomAction;
+import com.googlesource.gerrit.plugins.its.jira.workflow.InvokeIssueRestAPI;
+import com.googlesource.gerrit.plugins.its.jira.workflow.InvokeProjectRestAPI;
 import com.googlesource.gerrit.plugins.its.jira.workflow.MarkPropertyAsReleasedVersion;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -62,6 +64,12 @@
     bind(ItsConfig.class);
     bind(JiraItsServerInfoProvider.class);
     bind(CustomAction.class)
+        .annotatedWith(Exports.named(InvokeProjectRestAPI.ACTION_NAME))
+        .to(InvokeProjectRestAPI.class);
+    bind(CustomAction.class)
+        .annotatedWith(Exports.named(InvokeIssueRestAPI.ACTION_NAME))
+        .to(InvokeIssueRestAPI.class);
+    bind(CustomAction.class)
         .annotatedWith(Exports.named(MarkPropertyAsReleasedVersion.ACTION_NAME))
         .to(MarkPropertyAsReleasedVersion.class);
     install(new ItsHookModule(pluginName, pluginCfgFactory));
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java
index bcd10a5..c24ff49 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApi.java
@@ -72,7 +72,7 @@
   public T doGet(String spec, int passCode, int[] failCodes) throws IOException {
     HttpURLConnection conn = openConnection(spec, "GET", false);
     try {
-      if (validateResponse(conn, passCode, failCodes)) {
+      if (validateResponse(conn, new int[] {passCode}, failCodes)) {
         readIncomingData(conn);
       }
       return data;
@@ -117,10 +117,15 @@
 
   private boolean sendPayload(String method, String spec, String jsonInput, int passCode)
       throws IOException {
+    return sendPayload(method, spec, jsonInput, new int[] {passCode});
+  }
+
+  public boolean sendPayload(String method, String spec, String jsonInput, int[] passCodes)
+      throws IOException {
     HttpURLConnection conn = openConnection(spec, method, true);
     try {
       writeBodyData(jsonInput, conn);
-      return validateResponse(conn, passCode, null);
+      return validateResponse(conn, passCodes, null);
     } finally {
       conn.disconnect();
     }
@@ -148,10 +153,10 @@
    * IOException exception is thrown. If it was part of the list, then the actual response code is
    * returned. returns true if valid response is returned, otherwise false
    */
-  private boolean validateResponse(HttpURLConnection conn, int passCode, int[] failCodes)
+  private boolean validateResponse(HttpURLConnection conn, int[] passCodes, int[] failCodes)
       throws IOException {
     responseCode = conn.getResponseCode();
-    if (responseCode == passCode) {
+    if (ArrayUtils.contains(passCodes, responseCode)) {
       return true;
     }
     if ((failCodes == null) || (!ArrayUtils.contains(failCodes, responseCode))) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeIssueRestAPI.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeIssueRestAPI.java
new file mode 100644
index 0000000..f798d64
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeIssueRestAPI.java
@@ -0,0 +1,83 @@
+// 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.its.jira.workflow;
+
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.its.base.ItsPath;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
+import com.googlesource.gerrit.plugins.its.base.workflow.ActionType;
+import com.googlesource.gerrit.plugins.its.base.workflow.CustomAction;
+import com.googlesource.gerrit.plugins.its.jira.JiraClient;
+import com.googlesource.gerrit.plugins.its.jira.JiraItsFacade;
+import com.googlesource.gerrit.plugins.its.jira.JiraItsServerInfo;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class InvokeIssueRestAPI implements CustomAction {
+
+  private static final Logger log = LoggerFactory.getLogger(InvokeIssueRestAPI.class);
+
+  public static final String ACTION_NAME = "invoke-issue-restapi";
+
+  private final JiraClient jiraClient;
+  private final InvokeRestAPIParametersExtractor parametersExtractor;
+  private final SoyTemplateRenderer soyTemplateRenderer;
+
+  @Inject
+  public InvokeIssueRestAPI(
+      @ItsPath Path itsPath,
+      JiraClient jiraClient,
+      InvokeRestAPIParametersExtractor parametersExtractor) {
+    this.jiraClient = jiraClient;
+    this.parametersExtractor = parametersExtractor;
+    this.soyTemplateRenderer = new SoyTemplateRenderer(itsPath);
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String issueKey, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    Optional<InvokeRestAPIParameters> _parameters =
+        parametersExtractor.extract(actionRequest, properties);
+    if (!_parameters.isPresent()) {
+      return;
+    }
+    InvokeRestAPIParameters parameters = _parameters.get();
+
+    if (!JiraItsFacade.class.isInstance(its)) {
+      throw new IllegalArgumentException("Incorrect facade type");
+    }
+    JiraItsFacade jits = JiraItsFacade.class.cast(its);
+    JiraItsServerInfo jiraItsServerInfo = jits.getJiraServerInstance();
+
+    jiraClient.invokeIssueRestAPI(
+        jiraItsServerInfo,
+        issueKey,
+        parameters.getMethod(),
+        parameters.getUri(),
+        parameters.getPassCodes(),
+        soyTemplateRenderer.render(parameters.getTemplate(), properties));
+  }
+
+  @Override
+  public ActionType getType() {
+    return ActionType.ISSUE;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeProjectRestAPI.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeProjectRestAPI.java
new file mode 100644
index 0000000..c2cec94
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeProjectRestAPI.java
@@ -0,0 +1,84 @@
+// 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.its.jira.workflow;
+
+import com.google.gerrit.entities.Project;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.its.base.ItsPath;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
+import com.googlesource.gerrit.plugins.its.base.workflow.ActionType;
+import com.googlesource.gerrit.plugins.its.base.workflow.CustomAction;
+import com.googlesource.gerrit.plugins.its.jira.JiraClient;
+import com.googlesource.gerrit.plugins.its.jira.JiraItsServerInfo;
+import com.googlesource.gerrit.plugins.its.jira.JiraItsServerInfoProvider;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class InvokeProjectRestAPI implements CustomAction {
+
+  private static final Logger log = LoggerFactory.getLogger(InvokeProjectRestAPI.class);
+
+  public static final String ACTION_NAME = "invoke-project-restapi";
+
+  private final JiraItsServerInfoProvider serverInfoProvider;
+  private final JiraClient jiraClient;
+  private final InvokeRestAPIParametersExtractor parametersExtractor;
+  private final SoyTemplateRenderer soyTemplateRenderer;
+
+  @Inject
+  public InvokeProjectRestAPI(
+      @ItsPath Path itsPath,
+      JiraItsServerInfoProvider serverInfoProvider,
+      JiraClient jiraClient,
+      InvokeRestAPIParametersExtractor parametersExtractor) {
+    this.serverInfoProvider = serverInfoProvider;
+    this.jiraClient = jiraClient;
+    this.parametersExtractor = parametersExtractor;
+    this.soyTemplateRenderer = new SoyTemplateRenderer(itsPath);
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String itsProject, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    Optional<InvokeRestAPIParameters> _parameters =
+        parametersExtractor.extract(actionRequest, properties);
+    if (!_parameters.isPresent()) {
+      return;
+    }
+    InvokeRestAPIParameters parameters = _parameters.get();
+
+    Project.NameKey projectName = Project.nameKey(properties.get("project"));
+    JiraItsServerInfo jiraItsServerInfo = serverInfoProvider.get(projectName);
+
+    jiraClient.invokeProjectRestAPI(
+        jiraItsServerInfo,
+        itsProject,
+        parameters.getMethod(),
+        parameters.getUri(),
+        parameters.getPassCodes(),
+        soyTemplateRenderer.render(parameters.getTemplate(), properties));
+  }
+
+  @Override
+  public ActionType getType() {
+    return ActionType.PROJECT;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeRestAPIParameters.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeRestAPIParameters.java
new file mode 100644
index 0000000..ce5313a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeRestAPIParameters.java
@@ -0,0 +1,47 @@
+// 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.its.jira.workflow;
+
+/** Parameters needed by {@link InvokeIssueRestAPI} and {@link InvokeProjectsRestAPI} actions */
+public class InvokeRestAPIParameters {
+
+  private final String method;
+  private final String uri;
+  private final int[] passCodes;
+  private final String template;
+
+  public InvokeRestAPIParameters(String method, String uri, int[] passCodes, String template) {
+    this.method = method;
+    this.uri = uri;
+    this.passCodes = passCodes;
+    this.template = template;
+  }
+
+  public String getMethod() {
+    return method;
+  }
+
+  public String getUri() {
+    return uri;
+  }
+
+  public int[] getPassCodes() {
+    return passCodes;
+  }
+
+  public String getTemplate() {
+    return template;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeRestAPIParametersExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeRestAPIParametersExtractor.java
new file mode 100644
index 0000000..b99141e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/InvokeRestAPIParametersExtractor.java
@@ -0,0 +1,68 @@
+// 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.its.jira.workflow;
+
+import com.google.common.base.Strings;
+import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class InvokeRestAPIParametersExtractor {
+
+  private static final Logger log = LoggerFactory.getLogger(InvokeRestAPIParametersExtractor.class);
+
+  @Inject
+  public InvokeRestAPIParametersExtractor() {}
+
+  public Optional<InvokeRestAPIParameters> extract(
+      ActionRequest actionRequest, Map<String, String> properties) {
+    String[] parameters = actionRequest.getParameters();
+    if (parameters.length != 4) {
+      log.error(
+          "Wrong number of received parameters. Received parameters are {}. Three parameters are"
+              + " expected, method, uri, passCodes and template.",
+          Arrays.toString(parameters));
+      return Optional.empty();
+    }
+
+    String method = parameters[0];
+    if (Strings.isNullOrEmpty(method)) {
+      log.error("Received property id is blank");
+      return Optional.empty();
+    }
+    String uri = parameters[1];
+    if (Strings.isNullOrEmpty(uri)) {
+      log.error("Received property uri is blank");
+      return Optional.empty();
+    }
+    String passCodesStr = parameters[2];
+    if (Strings.isNullOrEmpty(passCodesStr)) {
+      log.error("Received property passCodes is blank");
+      return Optional.empty();
+    }
+    int[] passCodes = Arrays.stream(passCodesStr.split(",")).mapToInt(Integer::parseInt).toArray();
+    String template = parameters[3];
+    if (Strings.isNullOrEmpty(template)) {
+      log.error("Received property template is blank");
+      return Optional.empty();
+    }
+
+    return Optional.of(new InvokeRestAPIParameters(method, uri, passCodes, template));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/SoyTemplateRenderer.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/SoyTemplateRenderer.java
new file mode 100644
index 0000000..3463be8
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/SoyTemplateRenderer.java
@@ -0,0 +1,68 @@
+// 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.its.jira.workflow;
+
+import com.google.common.io.CharStreams;
+import com.google.inject.ProvisionException;
+import com.google.template.soy.SoyFileSet;
+import com.google.template.soy.jbcsrc.api.SoySauce.Renderer;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class SoyTemplateRenderer {
+
+  private static final Logger log = LoggerFactory.getLogger(SoyTemplateRenderer.class);
+
+  private final Path templateDir;
+
+  public SoyTemplateRenderer(Path itsPath) {
+    this.templateDir = itsPath.resolve("templates");
+  }
+
+  private String soyTextTemplate(
+      SoyFileSet.Builder builder, String template, Map<String, String> properties) {
+
+    Path templatePath = templateDir.resolve(template + ".soy");
+    String content;
+
+    try (Reader r = Files.newBufferedReader(templatePath, StandardCharsets.UTF_8)) {
+      content = CharStreams.toString(r);
+    } catch (IOException err) {
+      throw new ProvisionException(
+          "Failed to read template file " + templatePath.toAbsolutePath().toString(), err);
+    }
+
+    builder.add(content, templatePath.toAbsolutePath().toString());
+    Renderer renderer =
+        builder
+            .build()
+            .compileTemplates()
+            .renderTemplate("etc.its.templates." + template)
+            .setData(properties);
+    String rendered = renderer.renderText().get();
+    log.debug("Rendered template {} to:\n{}", templatePath, rendered);
+    return rendered;
+  }
+
+  public String render(String template, Map<String, String> properties) throws IOException {
+    return soyTextTemplate(SoyFileSet.builder(), template, properties);
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 4827dfd..cb34003 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -274,6 +274,67 @@
 Specific actions
 ----------------
 
+### invoke-issue-restapi
+
+The `invoke-issue-restapi` invokes RestAPI on the issue, it uses soy template to
+generate the request.
+
+```ini
+  action = invoke-issue-restapi method uri passCodes template
+```
+
+For example if you would like to create a link in issues to review, use the
+following action:
+
+```ini
+  action = invoke-issue-restapi POST /remotelink 200,201 link
+```
+
+With the follwing `its/templates/link.soy` template:
+
+```
+{namespace etc.its.templates}
+{template .link}
+  {@param changeUrl: string}
+  {@param subject: string}
+  {@param status: string}
+{lb}
+  "globalId": "{$changeUrl}",
+  "application": {lb}
+    "type": "com.googlesource.gerrit",
+    "name": "Gerrit"
+  {rb},
+  "object": {lb}
+    "url": "{$changeUrl}",
+    "title": "{$subject}",
+    "icon": {lb}
+      "url16x16": "https://www.gerritcodereview.com/images/diffy_logo.png",
+      "title": "Review"
+    {rb},
+    "status": {lb}
+      {switch $status}
+        {case null}
+          "resolved": false
+        {case 'NEW'}
+          "resolved": false
+        {case 'SUBMITTED'}
+          "resolved": false
+        {case 'MERGED'}
+          "resolved": true
+        {case 'ABANDONED'}
+          "resolved": true
+      {/switch}
+    {rb}
+  {rb}
+{rb}
+{/template}
+```
+
+### invoke-project-restapi
+
+The `invoke-project-restapi` is similar to `invoke-issue-restapi`, it invokes
+project method instead of issue method.
+
 ### mark-property-as-released-version
 
 The `mark-property-as-released-version` action marks a version as released in