Merge branch 'stable-2.16' into stable-3.0

* stable-2.16:
  Add its-jira configuration to the new PolyGerrit UI

Since stable-3.0, this change is not needed because PolyGerrit UI binds
automatically the configuration fields of the plugins.

Change-Id: I14b793907abc2222e20a3b28abbab056a5124509
diff --git a/BUILD b/BUILD
index 6f00197..eca48a1 100644
--- a/BUILD
+++ b/BUILD
@@ -5,8 +5,6 @@
     "PLUGIN_DEPS",
     "PLUGIN_TEST_DEPS",
 )
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "polygerrit_plugin")
 
 gerrit_plugin(
     name = "its-jira",
@@ -19,36 +17,12 @@
         "Implementation-Title: Jira ITS Plugin",
         "Implementation-URL: http://www.gerritforge.com",
     ],
-    resource_jars = [":cs-its-jira-static"],
     resources = glob(["src/main/resources/**/*"]),
     deps = [
         "//plugins/its-base",
     ],
 )
 
-genrule2(
-    name = "cs-its-jira-static",
-    srcs = [
-        ":cs-its-jira-config",
-    ],
-    outs = ["cs-its-jira-static.jar"],
-    cmd = " && ".join([
-        "mkdir $$TMP/static",
-        "cp -r $(locations :cs-its-jira-config) $$TMP/static",
-        "cd $$TMP",
-        "zip -Drq $$ROOT/$@ -g .",
-    ]),
-)
-
-polygerrit_plugin(
-    name = "cs-its-jira-config",
-    srcs = glob([
-        "cs-its-jira-config/*.html",
-        "cs-its-jira-config/*.js",
-    ]),
-    app = "plugin-config.html",
-)
-
 junit_tests(
     name = "its_jira_tests",
     testonly = 1,
diff --git a/cs-its-jira-config/cs-its-jira-config.html b/cs-its-jira-config/cs-its-jira-config.html
deleted file mode 100644
index c4f9f41..0000000
--- a/cs-its-jira-config/cs-its-jira-config.html
+++ /dev/null
@@ -1,82 +0,0 @@
-<!--
-@license
-Copyright (C) 2021 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.
--->
-
-<dom-module id="cs-its-jira-config">
-  <template>
-    <style include="shared-styles"></style>
-    <style include="gr-form-styles"></style>
-    <fieldset class="gr-form-styles">
-      <h2 class="sectionTitle">its-jira plugin</h2>
-      <section>
-        <section>
-          <span class="title">Enable its-jira integration</span>
-          <span class="value">
-            <gr-select id="enabled"
-                       bind-value="{{_changedConfig.enabled.value}}"
-                       on-change="_handlePrefsChanged">
-              <select disabled$="[[readOnly]]">
-                <option value="true">true</option>
-                <option value="false">false</option>
-                <option value="enforced">enforced</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <span class="title">Server URL</span>
-          <span class="value">
-            <iron-input bind-value="{{_changedConfig.instanceUrl.value}}">
-              <input id="instanceUrl"
-                     value="{{_changedConfig.instanceUrl.value::input}}"
-                     on-keypress="_handlePrefsChanged"
-                     on-change="_handlePrefsChanged"
-                     type="text">
-            </iron-input>
-          </span>
-        </section>
-        <section>
-          <span class="title">JIRA username</span>
-          <span class="value">
-            <iron-input bind-value="{{_changedConfig.username.value}}">
-              <input id="username"
-                     value="{{_changedConfig.username.value::input}}"
-                     on-keypress="_handlePrefsChanged"
-                     on-change="_handlePrefsChanged"
-                     type="text">
-            </iron-input>
-          </span>
-        </section>
-        <section>
-          <span class="title">JIRA password</span>
-          <span class="value">
-            <iron-input bind-value="{{_changedConfig.password.value}}">
-              <input id="password"
-                     value="{{_changedConfig.password.value::input}}"
-                     on-keypress="_handlePrefsChanged"
-                     on-change="_handlePrefsChanged"
-                     type="password">
-            </iron-input>
-          </span>
-        </section>
-        <gr-button
-            id="saveButton"
-            on-click="_handlePrefsSave"
-            disabled="[[!_prefsChanged]]">
-          Save Changes
-        </gr-button>
-      </section>
-    </fieldset>
-  </template>
-  <script src="cs-its-jira-config.js"></script>
-</dom-module>
diff --git a/cs-its-jira-config/cs-its-jira-config.js b/cs-its-jira-config/cs-its-jira-config.js
deleted file mode 100644
index 49b53b7..0000000
--- a/cs-its-jira-config/cs-its-jira-config.js
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2021 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.
-
-(function () {
-  'use strict';
-
-  Polymer({
-    is: 'cs-its-jira-config',
-
-    properties: {
-      repoName: String,
-      readOnly: {
-        type: Boolean,
-        value: true,
-      },
-      _config: Object,
-      _changedConfig: Object,
-      _prefsChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _projectRestApi: Object,
-    },
-
-    attached() {
-      this._projectRestApi = this.plugin.restApi('/projects/');
-      this._getPreferences().then(() => {
-        this._changedConfig = Object.assign({}, this._config);
-      })
-    },
-
-    _getPreferences() {
-      return this._projectRestApi.get(`${encodeURIComponent(this.repoName)}/config`)
-        .then(config => {
-          if (!config) {
-            return;
-          }
-          if (config.plugin_config && config.plugin_config["its-jira"]) {
-            this._config = config.plugin_config["its-jira"];
-          }
-        })
-    },
-
-    _handleListDataChanged(event) {
-      this._changedConfig[event.target.id] = { values: event.detail.allEntries };
-      this._handlePrefsChanged();
-    },
-
-    _handlePrefsChanged() {
-      this._prefsChanged = true;
-    },
-
-    _handlePrefsSave() {
-      let body = { plugin_config_values: {} };
-      body.plugin_config_values['its-jira'] = this._changedConfig;
-      this._projectRestApi.put(`${encodeURIComponent(this.repoName)}/config`, body)
-        .then(() => {
-          this._prefsChanged = false;
-        }).catch(response => {
-          this.fire('show-error', { message: response });
-        });
-    },
-  });
-})();
diff --git a/plugin-config.html b/plugin-config.html
deleted file mode 100644
index 22913ed..0000000
--- a/plugin-config.html
+++ /dev/null
@@ -1,27 +0,0 @@
-<!--
-Copyright (C) 2021 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.
--->
-
-<link rel="import" href="./cs-its-jira-config/cs-its-jira-config.html">
-
-<dom-module id="repo-its-jira-config">
-  <script>
-    if(window.Polymer) {
-      Gerrit.install(plugin => {
-        plugin.registerCustomComponent('repo-config', 'cs-its-jira-config');
-      });
-    }
-  </script>
-</dom-module>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java
index e362d3e..a5c16ec 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/InitJira.java
@@ -109,7 +109,10 @@
 
     ui.header("Jira issue-tracking association");
     jiraComment.string("Jira issue-Id regex", "match", "([A-Z]+-[0-9]+)");
-    jiraComment.set("html", String.format("<a href=\"%s/browse/$1\">$1</a>", jiraUrl));
+    jiraComment.string(
+        "What html would you like to use?",
+        "html",
+        String.format("<a href=\"%s/browse/$1\">$1</a>", jiraUrl));
 
     Section pluginConfig = sections.get("plugin", pluginName);
 
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 c17affd..5544df2 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
@@ -25,13 +25,18 @@
 import com.googlesource.gerrit.plugins.its.base.its.InvalidTransitionException;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraComment;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraIssue;
+import com.googlesource.gerrit.plugins.its.jira.restapi.JiraIssueUpdate;
+import com.googlesource.gerrit.plugins.its.jira.restapi.JiraPageRequest;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraProject;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraRestApi;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraRestApiProvider;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraServerInfo;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraTransition;
+import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVersion;
+import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVersionsPage;
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Date;
 import java.util.List;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -94,13 +99,77 @@
       log.debug("Trying to add comment for issue {}", issueKey);
       apiBuilder
           .getIssue(server)
-          .doPost(issueKey + "/comment", gson.toJson(new JiraComment(comment)), HTTP_CREATED);
+          .doPost(
+              issueKey + "/comment",
+              gson.toJson(new JiraComment(comment, server.getVisibility())),
+              HTTP_CREATED);
       log.debug("Comment added to 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);
+    JiraVersion jiraVersion = JiraVersion.builder().project(projectKey).name(version).build();
+    apiBuilder.getVersions(server).doPost("", gson.toJson(jiraVersion), HTTP_CREATED);
+    log.debug("Version {} created on project {}", version, projectKey);
+  }
+
+  public void markVersionAsReleased(JiraItsServerInfo server, String projectKey, String version)
+      throws IOException {
+    JiraVersion jiraVersion = findVersion(server, projectKey, version);
+    if (jiraVersion == null) {
+      log.error(
+          "Version {} of project {} does not exist or no access permission", version, projectKey);
+      return;
+    }
+
+    log.debug(
+        "Trying to mark version {} with id {} of project {} as released",
+        version,
+        jiraVersion.getId(),
+        projectKey);
+
+    JiraVersion markAsReleased =
+        JiraVersion.builder().released(true).releaseDate(new Date()).build();
+    apiBuilder.getVersions(server).doPut(jiraVersion.getId(), gson.toJson(markAsReleased), HTTP_OK);
+
+    log.debug("Version {} of project {} was marked as released", version, projectKey);
+  }
+
+  private JiraVersion findVersion(JiraItsServerInfo server, String projectKey, String version)
+      throws IOException {
+    JiraRestApi<JiraVersionsPage> api = apiBuilder.getProjectVersions(server, projectKey);
+
+    JiraPageRequest pageRequest = JiraPageRequest.builder().orderBy("-sequence").build();
+    JiraVersion jiraVersion = null;
+    while (pageRequest != null) {
+      JiraVersionsPage versionsPage = api.doGet(pageRequest.toSpec(), HTTP_OK);
+      jiraVersion = versionsPage.findByName(version);
+      if (jiraVersion != null) {
+        break;
+      }
+      pageRequest = versionsPage.nextPageRequest(pageRequest);
+    }
+
+    return jiraVersion;
+  }
+
+  public void addValueToField(
+      JiraItsServerInfo server, String issueKey, String value, String fieldId) throws IOException {
+    if (!issueExists(server, issueKey)) {
+      log.error("Issue {} does not exist", issueKey);
+      return;
+    }
+
+    log.debug("Trying to add value {} to field {} for issue {}", value, fieldId, issueKey);
+    JiraIssueUpdate edition = JiraIssueUpdate.builder().appendUpdate(fieldId, "add", value).build();
+    apiBuilder.getIssue(server).doPut(issueKey, gson.toJson(edition), HTTP_NO_CONTENT);
+    log.debug("Value {} added to field {} for issue {}", value, fieldId, issueKey);
+  }
+
   /**
    * @param issueKey Jira Issue key
    * @param transition JiraTransition.Item to perform
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java
index 5b4e197..baa93be 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraConfig.java
@@ -33,6 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraURL;
+import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVisibilityType;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -47,12 +48,16 @@
   static final String PROJECT_CONFIG_URL_KEY = "instanceUrl";
   static final String PROJECT_CONFIG_USERNAME_KEY = "username";
   static final String PROJECT_CONFIG_PASSWORD_KEY = "password";
+  static final String PROJECT_CONFIG_COMMENT_VISIBILITY_TYPE = "visibilityType";
+  static final String PROJECT_CONFIG_COMMENT_VISIBILITY_VALUE = "visibilityValue";
 
   private static final Logger log = LoggerFactory.getLogger(JiraConfig.class);
   private static final String COMMENTLINK = "commentlink";
   private static final String GERRIT_CONFIG_URL = "url";
   private static final String GERRIT_CONFIG_USERNAME = "username";
   private static final String GERRIT_CONFIG_PASSWORD = "password";
+  private static final String GERRIT_CONFIG_COMMENT_VISIBILITY_TYPE = "visibilityType";
+  private static final String GERRIT_CONFIG_COMMENT_VISIBILITY_VALUE = "visibilityValue";
 
   private final String pluginName;
   private final PluginConfigFactory cfgFactory;
@@ -60,6 +65,7 @@
   private final JiraItsServerInfo defaultJiraServerInfo;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
+  private final ProjectConfig.Factory projectConfigFactory;
   private final PersonIdent serverUser;
 
   @Inject
@@ -69,13 +75,15 @@
       PluginConfigFactory cfgFactory,
       @GerritPersonIdent PersonIdent serverUser,
       ProjectCache projectCache,
-      GitRepositoryManager repoManager) {
+      GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory) {
     this.gerritConfig = config;
     this.pluginName = pluginName;
     this.cfgFactory = cfgFactory;
     this.serverUser = serverUser;
     this.projectCache = projectCache;
     this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
     this.defaultJiraServerInfo = buildDefaultServerInfo(gerritConfig, pluginName);
   }
 
@@ -84,6 +92,10 @@
         .url(gerritConfig.getString(pluginName, null, GERRIT_CONFIG_URL))
         .username(gerritConfig.getString(pluginName, null, GERRIT_CONFIG_USERNAME))
         .password(gerritConfig.getString(pluginName, null, GERRIT_CONFIG_PASSWORD))
+        .visibility(
+            gerritConfig.getEnum(
+                pluginName, null, GERRIT_CONFIG_COMMENT_VISIBILITY_TYPE, JiraVisibilityType.NOTSET),
+            gerritConfig.getString(pluginName, null, GERRIT_CONFIG_COMMENT_VISIBILITY_VALUE))
         .build();
   }
 
@@ -101,13 +113,19 @@
         .url(pluginConfig.getString(PROJECT_CONFIG_URL_KEY, null))
         .username(pluginConfig.getString(PROJECT_CONFIG_USERNAME_KEY, null))
         .password(pluginConfig.getString(PROJECT_CONFIG_PASSWORD_KEY, null))
+        .visibility(
+            pluginConfig.getEnum(
+                JiraVisibilityType.values(),
+                PROJECT_CONFIG_COMMENT_VISIBILITY_TYPE,
+                JiraVisibilityType.NOTSET),
+            pluginConfig.getString(PROJECT_CONFIG_COMMENT_VISIBILITY_VALUE, null))
         .build();
   }
 
   void addCommentLinksSection(Project.NameKey projectName, JiraItsServerInfo jiraItsServerInfo) {
     try (Repository git = repoManager.openRepository(projectName);
         MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, git)) {
-      ProjectConfig config = ProjectConfig.read(md);
+      ProjectConfig config = projectConfigFactory.read(md);
       String link =
           CharMatcher.is('/').trimFrom(jiraItsServerInfo.getUrl().toString()) + JiraURL.URL_SUFFIX;
       if (!commentLinksExist(config, link)) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacade.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacade.java
index 00f965f..c67cabf 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacade.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacade.java
@@ -69,6 +69,17 @@
   }
 
   @Override
+  public void addValueToField(String issueKey, String value, String fieldId) throws IOException {
+    execute(
+        () -> {
+          log.debug("Adding value {} to field {} on issue {}", value, fieldId, issueKey);
+          jiraClient.addValueToField(itsServerInfo, issueKey, value, fieldId);
+          // No value to return
+          return null;
+        });
+  }
+
+  @Override
   public void performAction(String issueKey, String actionName) throws IOException {
     execute(
         () -> {
@@ -90,6 +101,16 @@
   }
 
   @Override
+  public void createVersion(String projectKey, String version) throws IOException {
+    execute(
+        () -> {
+          log.debug("Creating version {} on project {}", version, projectKey);
+          jiraClient.createVersion(itsServerInfo, projectKey, version);
+          return projectKey;
+        });
+  }
+
+  @Override
   public boolean exists(String issueKey) throws IOException {
     return execute(() -> jiraClient.issueExists(getJiraServerInstance(), issueKey));
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServer.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServer.java
index 0a698aa..7666239 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServer.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServer.java
@@ -24,16 +24,13 @@
  * to its-base to perform the its-actions.
  */
 public class JiraItsServer implements ItsFacadeFactory {
-  private final JiraConfig jiraConfig;
+  private final JiraItsServerInfoProvider serverInfoProvider;
   private final JiraItsFacade itsFacade;
-  private final JiraItsServerCache serverCache;
 
   @Inject
-  public JiraItsServer(
-      JiraConfig jiraConfig, JiraItsFacade itsFacade, JiraItsServerCache serverCache) {
-    this.jiraConfig = jiraConfig;
+  public JiraItsServer(JiraItsServerInfoProvider serverInfoProvider, JiraItsFacade itsFacade) {
+    this.serverInfoProvider = serverInfoProvider;
     this.itsFacade = itsFacade;
-    this.serverCache = serverCache;
   }
 
   /**
@@ -48,25 +45,7 @@
    */
   @Override
   public JiraItsFacade getFacade(Project.NameKey projectName) {
-    JiraItsServerInfo jiraItsServerInfo = serverCache.get(projectName.get());
-    if (jiraItsServerInfo.isValid()) {
-      jiraConfig.addCommentLinksSection(projectName, jiraItsServerInfo);
-    } else {
-      jiraItsServerInfo = jiraConfig.getDefaultServerInfo();
-    }
-
-    if (!jiraItsServerInfo.isValid()) {
-      throw new RuntimeException(
-          String.format(
-              "No valid Jira server configuration was found for project '%s' %n."
-                  + "Missing one or more configuration values: url: %s, username: %s, password: %s",
-              projectName.get(),
-              jiraItsServerInfo.getUrl(),
-              jiraItsServerInfo.getUsername(),
-              jiraItsServerInfo.getPassword()));
-    }
-
-    itsFacade.setJiraServerInstance(jiraItsServerInfo);
+    itsFacade.setJiraServerInstance(serverInfoProvider.get(projectName));
     return itsFacade;
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java
index b384189..4ffb72d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfo.java
@@ -16,9 +16,12 @@
 package com.googlesource.gerrit.plugins.its.jira;
 
 import com.googlesource.gerrit.plugins.its.jira.restapi.JiraURL;
+import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVisibility;
+import com.googlesource.gerrit.plugins.its.jira.restapi.JiraVisibilityType;
 import java.net.MalformedURLException;
 
 public class JiraItsServerInfo {
+
   public static class Builder {
     private JiraItsServerInfo instance = new JiraItsServerInfo();
 
@@ -43,6 +46,15 @@
       return this;
     }
 
+    public Builder visibility(JiraVisibilityType type, String value) {
+      try {
+        instance.visibility = new JiraVisibility(type, value);
+      } catch (IllegalArgumentException e) {
+        instance.visibility = null;
+      }
+      return this;
+    }
+
     public JiraItsServerInfo build() {
       return instance;
     }
@@ -51,6 +63,7 @@
   private JiraURL url;
   private String username;
   private String password;
+  private JiraVisibility visibility;
 
   public static Builder builder() {
     return new JiraItsServerInfo.Builder();
@@ -68,6 +81,10 @@
     return password;
   }
 
+  public JiraVisibility getVisibility() {
+    return visibility;
+  }
+
   public boolean isValid() {
     return url != null && username != null && password != null;
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfoProvider.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfoProvider.java
new file mode 100644
index 0000000..b5e0128
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfoProvider.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2019 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;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+
+public class JiraItsServerInfoProvider {
+
+  private final JiraConfig jiraConfig;
+  private final JiraItsServerCache serverCache;
+
+  @Inject
+  public JiraItsServerInfoProvider(JiraConfig jiraConfig, JiraItsServerCache serverCache) {
+    this.jiraConfig = jiraConfig;
+    this.serverCache = serverCache;
+  }
+
+  public JiraItsServerInfo get(Project.NameKey projectName) {
+    JiraItsServerInfo jiraItsServerInfo = serverCache.get(projectName.get());
+    if (jiraItsServerInfo.isValid()) {
+      jiraConfig.addCommentLinksSection(projectName, jiraItsServerInfo);
+    } else {
+      jiraItsServerInfo = jiraConfig.getDefaultServerInfo();
+    }
+
+    if (!jiraItsServerInfo.isValid()) {
+      throw new RuntimeException(
+          String.format(
+              "No valid Jira server configuration was found for project '%s' %n."
+                  + "Missing one or more configuration values: url: %s, username: %s, password: %s",
+              projectName.get(),
+              jiraItsServerInfo.getUrl(),
+              jiraItsServerInfo.getUsername(),
+              jiraItsServerInfo.getPassword()));
+    }
+
+    return jiraItsServerInfo;
+  }
+}
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 94d631e..3d59458 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
@@ -20,9 +20,6 @@
 
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.webui.JavaScriptPlugin;
-import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -31,6 +28,8 @@
 import com.googlesource.gerrit.plugins.its.base.its.ItsConfig;
 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.MarkPropertyAsReleasedVersion;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -61,11 +60,13 @@
         .annotatedWith(Exports.named(PROJECT_CONFIG_PASSWORD_KEY))
         .toInstance(new ProjectConfigEntry("JIRA password", ""));
     bind(ItsConfig.class);
+    bind(JiraItsServerInfoProvider.class);
+    bind(CustomAction.class)
+        .annotatedWith(Exports.named(MarkPropertyAsReleasedVersion.ACTION_NAME))
+        .to(MarkPropertyAsReleasedVersion.class);
     install(new ItsHookModule(pluginName, pluginCfgFactory));
     install(JiraItsServerCacheImpl.module());
     listener().to(JiraItsStartupHealthcheck.class);
-    DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("cs-its-jira-config.html"));
 
     LOG.info("JIRA is configured as ITS");
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraComment.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraComment.java
index eb2755d..d629018 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraComment.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraComment.java
@@ -17,9 +17,11 @@
 public class JiraComment {
 
   private final String body;
+  private final JiraVisibility visibility;
 
-  public JiraComment(String body) {
+  public JiraComment(String body, JiraVisibility visibility) {
     this.body = body;
+    this.visibility = visibility;
   }
 
   public String getBody() {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraIssueUpdate.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraIssueUpdate.java
new file mode 100644
index 0000000..bf9ea15
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraIssueUpdate.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2018 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.restapi;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class JiraIssueUpdate {
+
+  private final Map<String, List<Map<String, Object>>> update;
+
+  private JiraIssueUpdate(Map<String, List<Map<String, Object>>> update) {
+    this.update = Collections.unmodifiableMap(update);
+  }
+
+  public Map<String, List<Map<String, Object>>> getUpdate() {
+    return update;
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+    private final Map<String, List<Map<String, Object>>> update;
+
+    private Builder() {
+      this.update = new HashMap<>();
+    }
+
+    public Builder appendUpdate(String fieldId, String operation, String value) {
+      Object valueToPut = value;
+      if ("fixVersions".equals(fieldId)) {
+        valueToPut = Collections.singletonMap("name", value);
+      }
+
+      this.update
+          .computeIfAbsent(fieldId, key -> new ArrayList<>())
+          .add(Collections.singletonMap(operation, valueToPut));
+      return this;
+    }
+
+    public JiraIssueUpdate build() {
+      return new JiraIssueUpdate(update);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraPage.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraPage.java
new file mode 100644
index 0000000..3de05f4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraPage.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2018 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.restapi;
+
+import java.util.List;
+
+public class JiraPage<T> {
+
+  private final String self;
+  private final String nextPage;
+  private final long maxResults;
+  private final long startAt;
+  private final long total;
+  private final boolean isLast;
+  private final List<T> values;
+
+  public JiraPage(
+      String self,
+      String nextPage,
+      long maxResults,
+      long startAt,
+      long total,
+      boolean isLast,
+      List<T> values) {
+    this.self = self;
+    this.nextPage = nextPage;
+    this.maxResults = maxResults;
+    this.startAt = startAt;
+    this.total = total;
+    this.isLast = isLast;
+    this.values = values;
+  }
+
+  public JiraPageRequest nextPageRequest(JiraPageRequest currentPageRequest) {
+    if (isLast) {
+      return null;
+    }
+    return currentPageRequest.nextPageRequest();
+  }
+
+  public String getSelf() {
+    return self;
+  }
+
+  public String getNextPage() {
+    return nextPage;
+  }
+
+  public long getMaxResults() {
+    return maxResults;
+  }
+
+  public long getStartAt() {
+    return startAt;
+  }
+
+  public long getTotal() {
+    return total;
+  }
+
+  public boolean isLast() {
+    return isLast;
+  }
+
+  public List<T> getValues() {
+    return values;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraPageRequest.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraPageRequest.java
new file mode 100644
index 0000000..5e87ec3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraPageRequest.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2018 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.restapi;
+
+import com.google.common.base.Strings;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class JiraPageRequest {
+
+  private final Long startAt;
+  private final Long maxResults;
+  private final String orderBy;
+
+  private JiraPageRequest(Long startAt, Long maxResults, String orderBy) {
+    this.startAt = startAt;
+    this.maxResults = maxResults;
+    this.orderBy = orderBy;
+  }
+
+  public JiraPageRequest nextPageRequest() {
+    return new JiraPageRequest(startAt + 1, maxResults, orderBy);
+  }
+
+  public String toSpec() {
+    Map<String, Object> parameters = new HashMap<>();
+    if (startAt != null) {
+      parameters.put("startAt", startAt);
+    }
+    if (maxResults != null) {
+      parameters.put("maxResults", maxResults);
+    }
+    if (!Strings.isNullOrEmpty(orderBy)) {
+      parameters.put("orderBy", orderBy);
+    }
+    String requestParameters =
+        parameters
+            .entrySet()
+            .stream()
+            .map(parameter -> parameter.getKey() + "=" + parameter.getValue())
+            .collect(Collectors.joining("&"));
+    return "?" + requestParameters;
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+    private Long startAt;
+    private Long maxResults;
+    private String orderBy;
+
+    private Builder() {}
+
+    public Builder startAt(Long startAt) {
+      this.startAt = startAt;
+      return this;
+    }
+
+    public Builder maxResults(Long maxResults) {
+      this.maxResults = maxResults;
+      return this;
+    }
+
+    public Builder orderBy(String orderBy) {
+      this.orderBy = orderBy;
+      return this;
+    }
+
+    public JiraPageRequest build() {
+      return new JiraPageRequest(startAt, maxResults, orderBy);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiProvider.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiProvider.java
index 2e31d1b..9159068 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiProvider.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraRestApiProvider.java
@@ -39,4 +39,13 @@
   public JiraRestApi<JiraProject[]> getProjects(JiraItsServerInfo serverInfo) {
     return get(serverInfo, JiraProject[].class, "/project");
   }
+
+  public JiraRestApi<JiraVersion[]> getVersions(JiraItsServerInfo serverInfo) {
+    return get(serverInfo, JiraVersion[].class, "/version");
+  }
+
+  public JiraRestApi<JiraVersionsPage> getProjectVersions(
+      JiraItsServerInfo serverInfo, String projectKey) {
+    return get(serverInfo, JiraVersionsPage.class, "/project/" + projectKey + "/version");
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVersion.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVersion.java
new file mode 100644
index 0000000..36022be
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVersion.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2018 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.restapi;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/** Represents a version in JIRA. */
+public class JiraVersion {
+
+  private final String id;
+  private final String description;
+  private final String name;
+  private final boolean archived;
+  private final boolean released;
+  private final String releaseDate;
+  private final String project;
+  private final Long projectId;
+
+  private JiraVersion(
+      String id,
+      String description,
+      String name,
+      boolean archived,
+      boolean released,
+      Date releaseDate,
+      String project,
+      Long projectId) {
+    this.id = id;
+    this.description = description;
+    this.name = name;
+    this.archived = archived;
+    this.released = released;
+    if (releaseDate == null) {
+      this.releaseDate = null;
+    } else {
+      this.releaseDate = new SimpleDateFormat("yyyy-MM-dd").format(releaseDate);
+    }
+    this.project = project;
+    this.projectId = projectId;
+  }
+
+  public String getId() {
+    return id;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public boolean isArchived() {
+    return archived;
+  }
+
+  public boolean isReleased() {
+    return released;
+  }
+
+  public String getReleaseDate() {
+    return releaseDate;
+  }
+
+  public String getProject() {
+    return project;
+  }
+
+  public Long getProjectId() {
+    return projectId;
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+    private String id;
+    private String description;
+    private String name;
+    private boolean archived;
+    private boolean released;
+    private Date releaseDate;
+    private String project;
+    private Long projectId;
+
+    private Builder() {}
+
+    public Builder id(String id) {
+      this.id = id;
+      return this;
+    }
+
+    public Builder description(String description) {
+      this.description = description;
+      return this;
+    }
+
+    public Builder name(String name) {
+      this.name = name;
+      return this;
+    }
+
+    public Builder archived(boolean archived) {
+      this.archived = archived;
+      return this;
+    }
+
+    public Builder released(boolean released) {
+      this.released = released;
+      return this;
+    }
+
+    public Builder releaseDate(Date releaseDate) {
+      this.releaseDate = releaseDate;
+      return this;
+    }
+
+    public Builder project(String project) {
+      this.project = project;
+      return this;
+    }
+
+    public Builder projectId(Long projectId) {
+      this.projectId = projectId;
+      return this;
+    }
+
+    public JiraVersion build() {
+      return new JiraVersion(
+          id, description, name, archived, released, releaseDate, project, projectId);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVersionsPage.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVersionsPage.java
new file mode 100644
index 0000000..5fa684f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVersionsPage.java
@@ -0,0 +1,25 @@
+package com.googlesource.gerrit.plugins.its.jira.restapi;
+
+import java.util.List;
+
+/**
+ * Created on 03/06/18.
+ *
+ * @author Reda.Housni-Alaoui
+ */
+public class JiraVersionsPage extends JiraPage<JiraVersion> {
+  public JiraVersionsPage(
+      String self,
+      String nextPage,
+      int maxResults,
+      int startAt,
+      int total,
+      boolean isLast,
+      List<JiraVersion> values) {
+    super(self, nextPage, maxResults, startAt, total, isLast, values);
+  }
+
+  public JiraVersion findByName(String name) {
+    return this.getValues().stream().filter(v -> name.equals(v.getName())).findFirst().orElse(null);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVisibility.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVisibility.java
new file mode 100644
index 0000000..6037dbb
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVisibility.java
@@ -0,0 +1,46 @@
+// 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.its.jira.restapi;
+
+import com.googlesource.gerrit.plugins.its.jira.JiraItsServerInfo;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class JiraVisibility {
+  private static final Logger log = LoggerFactory.getLogger(JiraItsServerInfo.class);
+
+  private final JiraVisibilityType type;
+  private final String value;
+
+  public JiraVisibility(JiraVisibilityType type, String value) {
+    if (type != JiraVisibilityType.NOTSET && value != null) {
+      this.type = type;
+      this.value = value;
+    } else {
+      if (type != JiraVisibilityType.NOTSET || value != null) {
+        log.error("visibilityType and visibilityValue must be set together");
+      }
+      throw new IllegalArgumentException();
+    }
+  }
+
+  public JiraVisibilityType getType() {
+    return type;
+  }
+
+  public String getValue() {
+    return value;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVisibilityType.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVisibilityType.java
new file mode 100644
index 0000000..d9c7a29
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraVisibilityType.java
@@ -0,0 +1,36 @@
+// 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.its.jira.restapi;
+
+import com.google.gson.annotations.SerializedName;
+
+public enum JiraVisibilityType {
+  NOTSET(null),
+
+  @SerializedName("role")
+  ROLE("role"),
+  @SerializedName("group")
+  GROUP("group");
+
+  private String type;
+
+  JiraVisibilityType(String type) {
+    this.type = type;
+  }
+
+  public String toString() {
+    return this.type;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersion.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersion.java
new file mode 100644
index 0000000..db6de54
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersion.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2018 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.reviewdb.client.Project;
+import com.google.inject.Inject;
+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.util.Map;
+import java.util.Optional;
+
+public class MarkPropertyAsReleasedVersion implements CustomAction {
+
+  public static final String ACTION_NAME = "mark-property-as-released-version";
+
+  private final JiraItsServerInfoProvider serverInfoProvider;
+  private final JiraClient jiraClient;
+  private final MarkPropertyAsReleasedVersionParametersExtractor parametersExtractor;
+
+  @Inject
+  public MarkPropertyAsReleasedVersion(
+      JiraItsServerInfoProvider serverInfoProvider,
+      JiraClient jiraClient,
+      MarkPropertyAsReleasedVersionParametersExtractor parametersExtractor) {
+    this.serverInfoProvider = serverInfoProvider;
+    this.jiraClient = jiraClient;
+    this.parametersExtractor = parametersExtractor;
+  }
+
+  @Override
+  public void execute(
+      ItsFacade its, String itsProject, ActionRequest actionRequest, Map<String, String> properties)
+      throws IOException {
+    Optional<MarkPropertyAsReleasedVersionParameters> parameters =
+        parametersExtractor.extract(actionRequest, properties);
+    if (!parameters.isPresent()) {
+      return;
+    }
+    Project.NameKey projectName = new Project.NameKey(properties.get("project"));
+    JiraItsServerInfo jiraItsServerInfo = serverInfoProvider.get(projectName);
+    jiraClient.markVersionAsReleased(
+        jiraItsServerInfo, itsProject, parameters.get().getPropertyValue());
+  }
+
+  @Override
+  public ActionType getType() {
+    return ActionType.PROJECT;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParameters.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParameters.java
new file mode 100644
index 0000000..43b1a9a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParameters.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2018 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 MarkPropertyAsReleasedVersion} action */
+public class MarkPropertyAsReleasedVersionParameters {
+
+  private final String propertyValue;
+
+  public MarkPropertyAsReleasedVersionParameters(String propertyValue) {
+    this.propertyValue = propertyValue;
+  }
+
+  /**
+   * @return The extracted property value that will be used as the version value to mark as released
+   */
+  public String getPropertyValue() {
+    return propertyValue;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParametersExtractor.java b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParametersExtractor.java
new file mode 100644
index 0000000..d55e550
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParametersExtractor.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2018 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 MarkPropertyAsReleasedVersionParametersExtractor {
+
+  private static final Logger log =
+      LoggerFactory.getLogger(MarkPropertyAsReleasedVersionParametersExtractor.class);
+
+  @Inject
+  public MarkPropertyAsReleasedVersionParametersExtractor() {}
+
+  public Optional<MarkPropertyAsReleasedVersionParameters> extract(
+      ActionRequest actionRequest, Map<String, String> properties) {
+    String[] parameters = actionRequest.getParameters();
+    if (parameters.length != 1) {
+      log.error(
+          "Wrong number of received parameters. Received parameters are {}. Only one parameter is expected, the property id.",
+          Arrays.toString(parameters));
+      return Optional.empty();
+    }
+
+    String propertyId = parameters[0];
+    if (Strings.isNullOrEmpty(propertyId)) {
+      log.error("Received property id is blank");
+      return Optional.empty();
+    }
+
+    if (!properties.containsKey(propertyId)) {
+      log.error("No event property found for id {}", propertyId);
+      return Optional.empty();
+    }
+
+    String propertyValue = properties.get(propertyId);
+    return Optional.of(new MarkPropertyAsReleasedVersionParameters(propertyValue));
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index db66b9b..9e35208 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -149,20 +149,20 @@
 
     [rule "open"]
         event-type = patchset-created
-        action = add-velocity-comment inline Change ${its.formatLink($changeUrl)} is created.
+        action = add-standard-comment
         action = In Progress
     [rule "resolve"]
         event-type = comment-added
-        approval-Code-Review = 2
-        action = add-velocity-comment inline Change ${its.formatLink($changeUrl)} is verified.
+        approvalCodeReview = 2
+        action = add-standard-comment
         action = In Review
     [rule "merged"]
         event-type = change-merged
-        action = add-velocity-comment inline Change ${its.formatLink($changeUrl)} is merged.
+        action = add-standard-comment
         action = Done
     [rule "abandoned"]
-        event-type = change-abandoned'
-        action = add-velocity-comment inline Change ${its.formatLink($changeUrl)} is abandoned.
+        event-type = change-abandoned
+        action = add-standard-comment
         action = To Do
 
 The first rule triggers an action which adds a comment and a hyperlink to the change created
@@ -171,14 +171,6 @@
 in Jira to `In Progress`. The title of the action `In Progress` should match the workflow actions
 used by the JIRA server as different versions of JIRA can have different workflow actions.
 
-**Note:** Velocity comments were deprecated in Gerrit 2.14 and will be removed in Gerrit 2.16/3.0;
-the `actions-@Plugin@.config` needs to be changed accordingly. For example, to use Soy comments
-instead of velocity comments:
-
-    [rule "open"]
-        event-type = patchset-created
-        action = add-soy-comment Change ${its.formatLink($changeUrl)} is created.
-        action = In Progress
 
 Multiple Jira servers integration
 ---------------------------------
@@ -240,3 +232,44 @@
 limitation and the reason why this feature is marked as experimental, i.e., not
 production ready. Additional work is needed in order to offer a secure level of
 encryption for this information.
+
+Specific actions
+----------------
+
+### mark-property-as-released-version
+
+The `mark-property-as-released-version` action marks a version as released in
+JIRA.
+The version to mark as released is identified by an event property value.
+
+This is useful when you want to mark a version as released in JIRA when a
+tag is created in the Gerrit project.
+
+Example with the event property `ref`:
+
+```
+  action = mark-property-as-released-version ref
+```
+
+### add-comment
+
+The `add-comment`, `add-standard-comment` or `add-soy-comment` actions add comment for
+certain events. By default, these comments are visible to all on JIRA instance.
+
+It is possible to get better control over comments visibility by adding configuration
+entries in *gerrit.config* or *project.config* file in the *refs/meta/config* branch.
+
+A typical visibility configuration will look like:
+
+```
+  [plugin "its-jira"]
+    visibilityType = role
+    visibilityValue = Dev
+```
+
+This will publish comments visible only by users with *role* set as *Dev* in JIRA.
+
+`visibilityType` and `visibilityValue` must be set together.
+
+`visibilityType` could be set to **role** or **group**, and `visibilityValue` refers
+to JIRA *role* or *group*.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraConfigTest.java
index 66e151e..5348e8f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraConfigTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
@@ -44,13 +45,21 @@
   @Mock private PersonIdent serverUser;
   @Mock private ProjectCache projectCache;
   @Mock private GitRepositoryManager repoManager;
+  @Mock private ProjectConfig.Factory projectConfigFactory;
 
   private JiraConfig jiraConfig;
 
   @Before
   public void createJiraConfig() {
     jiraConfig =
-        new JiraConfig(cfg, PLUGIN_NAME, cfgFactory, serverUser, projectCache, repoManager);
+        new JiraConfig(
+            cfg,
+            PLUGIN_NAME,
+            cfgFactory,
+            serverUser,
+            projectCache,
+            repoManager,
+            projectConfigFactory);
   }
 
   @Test
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraITTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraITTest.java
index 5ec0021..a955363 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraITTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraITTest.java
@@ -15,12 +15,15 @@
 package com.googlesource.gerrit.plugins.its.jira;
 
 import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
 import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath;
 import static com.github.tomakehurst.wiremock.client.WireMock.noContent;
 import static com.github.tomakehurst.wiremock.client.WireMock.ok;
 import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
 import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
 import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
 import static java.lang.String.format;
 import static java.net.HttpURLConnection.HTTP_CREATED;
 import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
@@ -68,7 +71,7 @@
 
   private Path its_dir;
 
-  @Rule public WireMockRule wireMockRule = new WireMockRule(PORT);
+  @Rule public WireMockRule wireMockRule = new WireMockRule(options().port(PORT));
 
   @Override
   public void beforeTest(Description description) throws Exception {
@@ -210,6 +213,30 @@
     verifyIssueCall();
   }
 
+  @Test
+  @GerritConfig(name = COMMENT_SECTION + ".match", value = "([A-Z]+-[0-9]+)")
+  @GerritConfig(
+      name = COMMENT_SECTION + ".html",
+      value = "<a href=\"" + URL + "/browse/$1\">$1</a>")
+  @GerritConfig(name = COMMENT_SECTION + ".association", value = "SUGGESTED")
+  @GerritConfig(name = PLUGIN_NAME + ".url", value = URL)
+  @GerritConfig(name = PLUGIN_NAME + ".username", value = "user")
+  @GerritConfig(name = PLUGIN_NAME + ".password", value = "pass")
+  @GerritConfig(name = PLUGIN_NAME + ".visibilityType", value = "group")
+  @GerritConfig(name = PLUGIN_NAME + ".visibilityValue", value = "AllDev")
+  public void testIssueWithVisibility() throws Exception {
+    createItsRulesConfigWithComment();
+    mockServerCall();
+    mockCommentCall();
+    wireMockRule.givenThat(
+        WireMock.get(urlEqualTo(BASE_PREFIX + ISSUE_CLASS_PREFIX + JIRA_ISSUE)).willReturn(ok()));
+
+    createChangeWithIssue();
+
+    verifyIssueCall();
+    verifyCommentCallWithVisibility();
+  }
+
   private void mockServerCall() {
     wireMockRule.resetRequests();
     wireMockRule.givenThat(
@@ -255,9 +282,17 @@
             urlEqualTo(BASE_PREFIX + ISSUE_CLASS_PREFIX + JIRA_ISSUE + COMMENT_CLASS_PREFIX)));
   }
 
+  private void verifyCommentCallWithVisibility() {
+    wireMockRule.verify(
+        postRequestedFor(
+                urlEqualTo(BASE_PREFIX + ISSUE_CLASS_PREFIX + JIRA_ISSUE + COMMENT_CLASS_PREFIX))
+            .withRequestBody(matchingJsonPath("$.visibility.type", equalTo("group")))
+            .withRequestBody(matchingJsonPath("$.visibility.value", equalTo("AllDev"))));
+  }
+
   private void createChangeWithIssue() throws Exception {
     pushFactory
-        .create(db, admin.getIdent(), testRepo, JIRA_ISSUE, "a.txt", "test")
+        .create(admin.newIdent(), testRepo, JIRA_ISSUE, "a.txt", "test")
         .to("refs/for/master");
   }
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacadeTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacadeTest.java
index 9241d36..793f09f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacadeTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsFacadeTest.java
@@ -31,6 +31,9 @@
   private static final String ACTION = "action";
   private static final String COMMENT = "comment";
   private static final String ISSUE_KEY = "issueKey";
+  private static final String PROJECT_KEY = "projectKey";
+  private static final String FIELD_ID = "fieldId";
+  private static final String VALUE = "value";
 
   @Mock private JiraClient jiraClient;
   private JiraItsServerInfo server;
@@ -65,6 +68,13 @@
   }
 
   @Test
+  public void addValueToField() throws IOException {
+    jiraFacade = new JiraItsFacade(jiraClient);
+    jiraFacade.addValueToField(ISSUE_KEY, VALUE, FIELD_ID);
+    verify(jiraClient).addValueToField(server, ISSUE_KEY, VALUE, FIELD_ID);
+  }
+
+  @Test
   public void performAction() throws IOException, InvalidTransitionException {
     jiraFacade = new JiraItsFacade(jiraClient);
     jiraFacade.performAction(ISSUE_KEY, ACTION);
@@ -72,6 +82,13 @@
   }
 
   @Test
+  public void createVersion() throws IOException {
+    jiraFacade = new JiraItsFacade(jiraClient);
+    jiraFacade.createVersion(PROJECT_KEY, "1.0");
+    verify(jiraClient).createVersion(server, PROJECT_KEY, "1.0");
+  }
+
+  @Test
   public void exists() throws IOException {
     jiraFacade = new JiraItsFacade(jiraClient);
     jiraFacade.exists(ISSUE_KEY);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfoProviderTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfoProviderTest.java
new file mode 100644
index 0000000..c8fabc7
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerInfoProviderTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2018 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;
+
+import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.reviewdb.client.Project;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class JiraItsServerInfoProviderTest {
+
+  private static final Project.NameKey PROJECT_NAMEKEY = new Project.NameKey("project");
+
+  @Mock private JiraConfig jiraConfig;
+  @Mock private JiraItsServerCache serverCache;
+  @Mock private JiraItsServerInfo jiraItsServerInfo;
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private JiraItsServerInfoProvider jiraItsServerInfoProvider;
+
+  @Test
+  public void testValidServerInfoIsreturnedFromTheCache() {
+    when(jiraItsServerInfo.isValid()).thenReturn(true);
+    when(serverCache.get(PROJECT_NAMEKEY.get())).thenReturn(jiraItsServerInfo);
+    jiraItsServerInfoProvider = new JiraItsServerInfoProvider(jiraConfig, serverCache);
+    jiraItsServerInfoProvider.get(PROJECT_NAMEKEY);
+    verify(jiraConfig).addCommentLinksSection(PROJECT_NAMEKEY, jiraItsServerInfo);
+  }
+
+  @Test
+  public void testGetDefaultServerInfo() {
+    when(jiraItsServerInfo.isValid()).thenReturn(false).thenReturn(true);
+    when(serverCache.get(PROJECT_NAMEKEY.get())).thenReturn(jiraItsServerInfo);
+    when(jiraConfig.getDefaultServerInfo()).thenReturn(jiraItsServerInfo);
+    jiraItsServerInfoProvider = new JiraItsServerInfoProvider(jiraConfig, serverCache);
+    jiraItsServerInfoProvider.get(PROJECT_NAMEKEY);
+    verify(jiraConfig, never()).addCommentLinksSection(PROJECT_NAMEKEY, jiraItsServerInfo);
+  }
+
+  @Test
+  public void testNoConfiguredServerInfo() {
+    when(serverCache.get(PROJECT_NAMEKEY.get())).thenReturn(jiraItsServerInfo);
+    when(jiraItsServerInfo.isValid()).thenReturn(false).thenReturn(false);
+    when(jiraConfig.getDefaultServerInfo()).thenReturn(jiraItsServerInfo);
+    jiraItsServerInfoProvider = new JiraItsServerInfoProvider(jiraConfig, serverCache);
+    expectedException.expect(RuntimeException.class);
+    jiraItsServerInfoProvider.get(PROJECT_NAMEKEY);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerTest.java
index 06daa15..78cb0fb 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/JiraItsServerTest.java
@@ -14,7 +14,6 @@
 
 package com.googlesource.gerrit.plugins.its.jira;
 
-import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -30,43 +29,17 @@
 public class JiraItsServerTest {
   private static final Project.NameKey PROJECT_NAMEKEY = new Project.NameKey("project");
 
-  @Mock private JiraConfig jiraConfig;
+  @Mock private JiraItsServerInfoProvider jiraItsserverInfoProvider;
   @Mock private JiraItsFacade itsFacade;
-  @Mock private JiraItsServerCache serverCache;
   @Mock private JiraItsServerInfo jiraItsServerInfo;
 
   @Rule public ExpectedException expectedException = ExpectedException.none();
 
-  private JiraItsServer jiraItsServer;
-
   @Test
-  public void testValidServerInfoIsreturnedFromTheCache() throws Exception {
-    when(jiraItsServerInfo.isValid()).thenReturn(true);
-    when(serverCache.get(PROJECT_NAMEKEY.get())).thenReturn(jiraItsServerInfo);
-    jiraItsServer = new JiraItsServer(jiraConfig, itsFacade, serverCache);
+  public void testGetFacade() {
+    when(jiraItsserverInfoProvider.get(PROJECT_NAMEKEY)).thenReturn(jiraItsServerInfo);
+    JiraItsServer jiraItsServer = new JiraItsServer(jiraItsserverInfoProvider, itsFacade);
     jiraItsServer.getFacade(PROJECT_NAMEKEY);
-    verify(jiraConfig).addCommentLinksSection(PROJECT_NAMEKEY, jiraItsServerInfo);
     verify(itsFacade).setJiraServerInstance(jiraItsServerInfo);
   }
-
-  @Test
-  public void testGetDefaultServerInfo() throws Exception {
-    when(jiraItsServerInfo.isValid()).thenReturn(false).thenReturn(true);
-    when(serverCache.get(PROJECT_NAMEKEY.get())).thenReturn(jiraItsServerInfo);
-    when(jiraConfig.getDefaultServerInfo()).thenReturn(jiraItsServerInfo);
-    jiraItsServer = new JiraItsServer(jiraConfig, itsFacade, serverCache);
-    jiraItsServer.getFacade(PROJECT_NAMEKEY);
-    verify(jiraConfig, never()).addCommentLinksSection(PROJECT_NAMEKEY, jiraItsServerInfo);
-    verify(itsFacade).setJiraServerInstance(jiraItsServerInfo);
-  }
-
-  @Test
-  public void testNoConfiguredServerInfo() throws Exception {
-    when(serverCache.get(PROJECT_NAMEKEY.get())).thenReturn(jiraItsServerInfo);
-    when(jiraItsServerInfo.isValid()).thenReturn(false).thenReturn(false);
-    when(jiraConfig.getDefaultServerInfo()).thenReturn(jiraItsServerInfo);
-    jiraItsServer = new JiraItsServer(jiraConfig, itsFacade, serverCache);
-    expectedException.expect(RuntimeException.class);
-    jiraItsServer.getFacade(PROJECT_NAMEKEY);
-  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraIssueUpdateTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraIssueUpdateTest.java
new file mode 100644
index 0000000..1ecba22
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/restapi/JiraIssueUpdateTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2018 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.restapi;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.json.OutputFormat;
+import com.google.gson.Gson;
+import org.junit.Test;
+
+public class JiraIssueUpdateTest {
+
+  private static final String FIELD_ID = "fieldId";
+  private static final String OPERATION = "operation";
+  private static final String VALUE = "value";
+
+  @Test
+  public void testSerialization() {
+    JiraIssueUpdate issueUpdate =
+        JiraIssueUpdate.builder().appendUpdate(FIELD_ID, OPERATION, VALUE).build();
+
+    assertThat(newGson().toJson(issueUpdate))
+        .isEqualTo("{\"update\":{\"fieldId\":[{\"operation\":\"value\"}]}}");
+  }
+
+  @Test
+  public void testSerializationForFixVersions() {
+    JiraIssueUpdate issueUpdate =
+        JiraIssueUpdate.builder().appendUpdate("fixVersions", OPERATION, VALUE).build();
+
+    assertThat(newGson().toJson(issueUpdate))
+        .isEqualTo("{\"update\":{\"fixVersions\":[{\"operation\":{\"name\":\"value\"}}]}}");
+  }
+
+  private Gson newGson() {
+    return OutputFormat.JSON_COMPACT.newGson();
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParametersExtractorTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParametersExtractorTest.java
new file mode 100644
index 0000000..81dc49a
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionParametersExtractorTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2018 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 static junit.framework.TestCase.assertTrue;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
+import java.util.Collections;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MarkPropertyAsReleasedVersionParametersExtractorTest {
+
+  private static final String PROPERTY_ID = "propertyId";
+  private static final String PROPERTY_VALUE = "propertyValue";
+
+  private MarkPropertyAsReleasedVersionParametersExtractor extractor;
+
+  @Before
+  public void before() {
+    extractor = new MarkPropertyAsReleasedVersionParametersExtractor();
+  }
+
+  @Test
+  public void testNoParameter() {
+    testWrongNumberOfReceivedParameters(new String[] {});
+  }
+
+  @Test
+  public void testTwoParameters() {
+    testWrongNumberOfReceivedParameters(new String[] {PROPERTY_ID, PROPERTY_ID});
+  }
+
+  private void testWrongNumberOfReceivedParameters(String[] parameters) {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(parameters);
+
+    Optional<MarkPropertyAsReleasedVersionParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  @Test
+  public void testBlankPropertyId() {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(new String[] {""});
+
+    Optional<MarkPropertyAsReleasedVersionParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  @Test
+  public void testUnknownPropertyId() {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(new String[] {PROPERTY_ID});
+
+    Optional<MarkPropertyAsReleasedVersionParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.emptyMap());
+    assertFalse(extractedParameters.isPresent());
+  }
+
+  @Test
+  public void testHappyPath() {
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    when(actionRequest.getParameters()).thenReturn(new String[] {PROPERTY_ID});
+
+    Optional<MarkPropertyAsReleasedVersionParameters> extractedParameters =
+        extractor.extract(actionRequest, Collections.singletonMap(PROPERTY_ID, PROPERTY_VALUE));
+    assertTrue(extractedParameters.isPresent());
+    assertEquals(PROPERTY_VALUE, extractedParameters.get().getPropertyValue());
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionTest.java b/src/test/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionTest.java
new file mode 100644
index 0000000..ad29c2a
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/its/jira/workflow/MarkPropertyAsReleasedVersionTest.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2018 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 static org.mockito.Mockito.*;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.googlesource.gerrit.plugins.its.base.its.ItsFacade;
+import com.googlesource.gerrit.plugins.its.base.workflow.ActionRequest;
+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.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+public class MarkPropertyAsReleasedVersionTest {
+
+  private static final String ITS_PROJECT = "test-project";
+  private static final String PROJECT_KEY = "project";
+  private static final String PROJECT_NAME = "projectName";
+  private static final String PROPERTY_ID = "propertyId";
+  private static final String PROPERTY_VALUE = "propertyValue";
+
+  private ItsFacade its;
+  private JiraItsServerInfo serverInfo;
+  private JiraClient jiraClient;
+  private MarkPropertyAsReleasedVersionParametersExtractor parametersExtractor;
+  private MarkPropertyAsReleasedVersion markPropertyAsReleasedVersion;
+
+  @Before
+  public void before() {
+    its = mock(ItsFacade.class);
+    JiraItsServerInfoProvider serverInfoProvider = mock(JiraItsServerInfoProvider.class);
+    serverInfo = mock(JiraItsServerInfo.class);
+    when(serverInfoProvider.get(new Project.NameKey(PROJECT_NAME))).thenReturn(serverInfo);
+    jiraClient = mock(JiraClient.class);
+    parametersExtractor = mock(MarkPropertyAsReleasedVersionParametersExtractor.class);
+    markPropertyAsReleasedVersion =
+        new MarkPropertyAsReleasedVersion(serverInfoProvider, jiraClient, parametersExtractor);
+  }
+
+  @Test
+  public void testHappyPath() throws IOException {
+    MarkPropertyAsReleasedVersionParameters extractedParameters =
+        mock(MarkPropertyAsReleasedVersionParameters.class);
+    when(extractedParameters.getPropertyValue()).thenReturn(PROPERTY_VALUE);
+
+    ActionRequest actionRequest = mock(ActionRequest.class);
+    Map<String, String> properties = buildProperties();
+    when(parametersExtractor.extract(actionRequest, properties))
+        .thenReturn(Optional.of(extractedParameters));
+
+    markPropertyAsReleasedVersion.execute(its, ITS_PROJECT, actionRequest, properties);
+
+    verify(jiraClient).markVersionAsReleased(serverInfo, ITS_PROJECT, PROPERTY_VALUE);
+  }
+
+  private Map<String, String> buildProperties() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(PROPERTY_ID, PROJECT_NAME);
+    properties.put(PROJECT_KEY, PROJECT_NAME);
+    return properties;
+  }
+}