You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by ra...@apache.org on 2019/12/18 09:56:52 UTC

[sling-org-apache-sling-committer-cli] branch master updated: SLING-8392 - Create sub-command to manage the Jira update when promoting a release

This is an automated email from the ASF dual-hosted git repository.

radu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-committer-cli.git


The following commit(s) were added to refs/heads/master by this push:
     new a0456a9  SLING-8392 - Create sub-command to manage the Jira update when promoting a release
a0456a9 is described below

commit a0456a9033c04b62316b744d4a1fb3d66e46dd06
Author: Radu Cotescu <17...@users.noreply.github.com>
AuthorDate: Wed Dec 18 10:56:43 2019 +0100

    SLING-8392 - Create sub-command to manage the Jira update when promoting a release
---
 pom.xml                                            |   6 +
 src/main/features/app.json                         |   4 +
 .../org/apache/sling/cli/impl/DateProvider.java    |   8 +-
 .../java/org/apache/sling/cli/impl/jira/Issue.java |  26 +++-
 .../{DateProvider.java => jira/Transition.java}    |  27 ++--
 .../TransitionsResponse.java}                      |  22 +--
 .../org/apache/sling/cli/impl/jira/Version.java    |  21 ++-
 .../apache/sling/cli/impl/jira/VersionClient.java  | 165 ++++++++++++++++++---
 .../impl/release/ReleaseJiraVersionCommand.java    | 105 +++++++++++++
 .../sling/cli/impl/http/HttpExchangeHandler.java   |  19 +--
 .../sling/cli/impl/jira/EditVersionJiraAction.java | 103 +++++++++++++
 .../cli/impl/jira/IssuesSearchJiraAction.java      |  22 ++-
 .../org/apache/sling/cli/impl/jira/MockJira.java   |  16 +-
 .../sling/cli/impl/jira/TransitionsJiraAction.java |  66 +++++++++
 .../sling/cli/impl/jira/VersionClientTest.java     |  70 +++++++--
 src/test/resources/jira/relatedIssueCounts/0.json  |   6 +
 src/test/resources/jira/relatedIssueCounts/1.json  |   6 +
 ...ter-cli-1.0.0.json => committer-cli-1.0.0.json} |  84 ++++++++++-
 .../resources/jira/search/transitions-0.1.0.json   |   8 +
 .../resources/jira/search/transitions-1.0.0.json   |  53 +++++++
 .../resources/jira/search/transitions-2.0.0.json   |  53 +++++++
 .../search/unresolved-committer-cli-1.0.0.json     |  26 ----
 .../resources/jira/transitions/no-transitions.json |   5 +
 .../resources/jira/transitions/transitions.json    |  41 +++++
 src/test/resources/jira/versions.json              |  22 +++
 25 files changed, 874 insertions(+), 110 deletions(-)

diff --git a/pom.xml b/pom.xml
index 4aac3cb..59339ec 100644
--- a/pom.xml
+++ b/pom.xml
@@ -200,6 +200,12 @@
         </dependency>
         <dependency>
             <groupId>org.osgi</groupId>
+            <artifactId>osgi.promise</artifactId>
+            <version>7.0.1</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.osgi</groupId>
             <artifactId>osgi.core</artifactId>
         </dependency>
         <dependency>
diff --git a/src/main/features/app.json b/src/main/features/app.json
index 805b29c..4e6e732 100644
--- a/src/main/features/app.json
+++ b/src/main/features/app.json
@@ -66,6 +66,10 @@
             "start-level": "3"
         },
         {
+            "id"         : "org.osgi:osgi.promise:7.0.1",
+            "start-level": "4"
+        },
+        {
             "id": "org.bouncycastle:bcpg-jdk15on:1.62"
         },
         {
diff --git a/src/main/java/org/apache/sling/cli/impl/DateProvider.java b/src/main/java/org/apache/sling/cli/impl/DateProvider.java
index 627cca8..9e114d1 100644
--- a/src/main/java/org/apache/sling/cli/impl/DateProvider.java
+++ b/src/main/java/org/apache/sling/cli/impl/DateProvider.java
@@ -27,14 +27,20 @@ import org.osgi.service.component.annotations.Component;
 public class DateProvider {
 
     private static final DateTimeFormatter emailDateHeader = DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss Z");
+    private static final DateTimeFormatter jiraReleaseDate = DateTimeFormatter.ofPattern("yyyy-MM-d");
 
     public OffsetDateTime getCurrentDate() {
         return OffsetDateTime.now();
     }
 
     public String getCurrentDateForEmailHeader() {
-        OffsetDateTime offsetDateTime = OffsetDateTime.now();
+        OffsetDateTime offsetDateTime = getCurrentDate();
         return offsetDateTime.format(emailDateHeader);
     }
 
+    public String getCurrentDateForJiraRelease() {
+        OffsetDateTime offsetDateTime = getCurrentDate();
+        return offsetDateTime.format(jiraReleaseDate);
+    }
+
 }
diff --git a/src/main/java/org/apache/sling/cli/impl/jira/Issue.java b/src/main/java/org/apache/sling/cli/impl/jira/Issue.java
index 1fb3c6c..87c474f 100644
--- a/src/main/java/org/apache/sling/cli/impl/jira/Issue.java
+++ b/src/main/java/org/apache/sling/cli/impl/jira/Issue.java
@@ -33,9 +33,33 @@ public class Issue {
     public String getSummary() {
         return fields.summary;
     }
+
+    public String getStatus() {
+        if (fields.status != null) {
+            return fields.status.name;
+        }
+        return null;
+    }
+
+    public String getResolution() {
+        if (fields.resolution != null) {
+            return fields.resolution.name;
+        }
+        return null;
+    }
     
     static class Fields {
-        private String summary;    
+        private String summary;
+        private Status status;
+        private Resolution resolution;
+
+        static class Status {
+            private String name;
+        }
+
+        static class Resolution {
+            private String name;
+        }
     }
 
     @Override
diff --git a/src/main/java/org/apache/sling/cli/impl/DateProvider.java b/src/main/java/org/apache/sling/cli/impl/jira/Transition.java
similarity index 61%
copy from src/main/java/org/apache/sling/cli/impl/DateProvider.java
copy to src/main/java/org/apache/sling/cli/impl/jira/Transition.java
index 627cca8..e5a10df 100644
--- a/src/main/java/org/apache/sling/cli/impl/DateProvider.java
+++ b/src/main/java/org/apache/sling/cli/impl/jira/Transition.java
@@ -16,25 +16,26 @@
  ~ specific language governing permissions and limitations
  ~ under the License.
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
-package org.apache.sling.cli.impl;
+package org.apache.sling.cli.impl.jira;
 
-import java.time.OffsetDateTime;
-import java.time.format.DateTimeFormatter;
+public class Transition {
 
-import org.osgi.service.component.annotations.Component;
+    private int id;
+    private String name;
 
-@Component(service = DateProvider.class)
-public class DateProvider {
-
-    private static final DateTimeFormatter emailDateHeader = DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss Z");
+    public int getId() {
+        return id;
+    }
 
-    public OffsetDateTime getCurrentDate() {
-        return OffsetDateTime.now();
+    public void setId(int id) {
+        this.id = id;
     }
 
-    public String getCurrentDateForEmailHeader() {
-        OffsetDateTime offsetDateTime = OffsetDateTime.now();
-        return offsetDateTime.format(emailDateHeader);
+    public String getName() {
+        return name;
     }
 
+    public void setName(String name) {
+        this.name = name;
+    }
 }
diff --git a/src/main/java/org/apache/sling/cli/impl/DateProvider.java b/src/main/java/org/apache/sling/cli/impl/jira/TransitionsResponse.java
similarity index 61%
copy from src/main/java/org/apache/sling/cli/impl/DateProvider.java
copy to src/main/java/org/apache/sling/cli/impl/jira/TransitionsResponse.java
index 627cca8..f8f593a 100644
--- a/src/main/java/org/apache/sling/cli/impl/DateProvider.java
+++ b/src/main/java/org/apache/sling/cli/impl/jira/TransitionsResponse.java
@@ -16,25 +16,19 @@
  ~ specific language governing permissions and limitations
  ~ under the License.
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
-package org.apache.sling.cli.impl;
+package org.apache.sling.cli.impl.jira;
 
-import java.time.OffsetDateTime;
-import java.time.format.DateTimeFormatter;
+import java.util.List;
 
-import org.osgi.service.component.annotations.Component;
+public class TransitionsResponse {
 
-@Component(service = DateProvider.class)
-public class DateProvider {
+    private List<Transition> transitions;
 
-    private static final DateTimeFormatter emailDateHeader = DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss Z");
-
-    public OffsetDateTime getCurrentDate() {
-        return OffsetDateTime.now();
+    public List<Transition> getTransitions() {
+        return transitions;
     }
 
-    public String getCurrentDateForEmailHeader() {
-        OffsetDateTime offsetDateTime = OffsetDateTime.now();
-        return offsetDateTime.format(emailDateHeader);
+    public void setTransitions(List<Transition> transitions) {
+        this.transitions = transitions;
     }
-
 }
diff --git a/src/main/java/org/apache/sling/cli/impl/jira/Version.java b/src/main/java/org/apache/sling/cli/impl/jira/Version.java
index 173971e..0e9dffb 100644
--- a/src/main/java/org/apache/sling/cli/impl/jira/Version.java
+++ b/src/main/java/org/apache/sling/cli/impl/jira/Version.java
@@ -16,10 +16,13 @@
  */
 package org.apache.sling.cli.impl.jira;
 
+@SuppressWarnings("unused")
 public class Version {
     private int id;
     private String name;
     private int issuesFixedCount;
+    private boolean released;
+    private String releaseDate;
 
     public int getId() {
         return id;
@@ -44,7 +47,23 @@ public class Version {
     public void setRelatedIssuesCount(int relatedIssuesCount) {
         this.issuesFixedCount = relatedIssuesCount;
     }
-    
+
+    public boolean isReleased() {
+        return released;
+    }
+
+    public void setReleased(boolean released) {
+        this.released = released;
+    }
+
+    public String getReleaseDate() {
+        return releaseDate;
+    }
+
+    public void setReleaseDate(String releaseDate) {
+        this.releaseDate = releaseDate;
+    }
+
     @Override
     public String toString() {
         
diff --git a/src/main/java/org/apache/sling/cli/impl/jira/VersionClient.java b/src/main/java/org/apache/sling/cli/impl/jira/VersionClient.java
index 0621199..73b0dba 100644
--- a/src/main/java/org/apache/sling/cli/impl/jira/VersionClient.java
+++ b/src/main/java/org/apache/sling/cli/impl/jira/VersionClient.java
@@ -23,9 +23,11 @@ import java.io.StringWriter;
 import java.lang.reflect.Type;
 import java.net.URISyntaxException;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Predicate;
+import java.util.stream.Collectors;
 
 import org.apache.http.HttpHeaders;
 import org.apache.http.client.methods.CloseableHttpResponse;
@@ -36,12 +38,18 @@ import org.apache.http.client.utils.URIBuilder;
 import org.apache.http.entity.StringEntity;
 import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.sling.cli.impl.ComponentContextHelper;
+import org.apache.sling.cli.impl.DateProvider;
 import org.apache.sling.cli.impl.http.HttpClientFactory;
 import org.apache.sling.cli.impl.release.Release;
 import org.osgi.service.component.ComponentContext;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
+import org.osgi.util.promise.FailedPromisesException;
+import org.osgi.util.promise.Promise;
+import org.osgi.util.promise.PromiseFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.google.gson.Gson;
 import com.google.gson.JsonIOException;
@@ -54,19 +62,29 @@ import com.google.gson.stream.JsonWriter;
  */
 @Component(service = VersionClient.class)
 public class VersionClient {
-    
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(VersionClient.class);
+
     private static final String PROJECT_KEY = "SLING";
-    private static final String DEFAULT_JIRA_URL_PREFIX = "https://issues.apache.org/jira/rest/api/2/";
+    private static final String DEFAULT_JIRA_URL = "https://issues.apache.org/jira";
     private static final String CONTENT_TYPE_JSON = "application/json";
-    
+
+    private final PromiseFactory promiseFactory = new PromiseFactory(null, null);
+
     @Reference
     private HttpClientFactory httpClientFactory;
-    private String jiraUrlPrefix;
+
+    @Reference
+    private DateProvider dateProvider;
+
+    private String jiraRESTAPIEntrypoint;
+    private String jiraURL;
 
     @Activate
     protected void activate(ComponentContext ctx) {
         ComponentContextHelper helper = ComponentContextHelper.wrap(ctx);
-        jiraUrlPrefix = helper.getProperty("jira.url.prefix", DEFAULT_JIRA_URL_PREFIX);
+        jiraURL = helper.getProperty("jira.url", DEFAULT_JIRA_URL);
+        jiraRESTAPIEntrypoint = jiraURL + "/rest/api/2/";
     }
 
     /**
@@ -160,35 +178,102 @@ public class VersionClient {
         }
     }
 
+    private HttpGet newGet(String suffix) {
+        HttpGet get = new HttpGet(jiraRESTAPIEntrypoint + suffix);
+        get.addHeader(HttpHeaders.ACCEPT, CONTENT_TYPE_JSON);
+        return get;
+    }
+
     private HttpPost newPost(String suffix) {
-        HttpPost post = new HttpPost(jiraUrlPrefix + suffix);
+        HttpPost post = new HttpPost(jiraRESTAPIEntrypoint + suffix);
         post.addHeader(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_JSON);
         post.addHeader(HttpHeaders.ACCEPT, CONTENT_TYPE_JSON);
         return post;
     }
     
     private HttpPut newPut(String suffix) {
-        HttpPut put = new HttpPut(jiraUrlPrefix + suffix);
+        HttpPut put = new HttpPut(jiraRESTAPIEntrypoint + suffix);
         put.addHeader(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_JSON);
         put.addHeader(HttpHeaders.ACCEPT, CONTENT_TYPE_JSON);
         return put;
     }
     
     public List<Issue> findUnresolvedIssues(Release release) throws IOException {
-        return findIssues("is empty", release);
+        return findIssues(release).stream().filter(issue -> issue.getResolution() == null).collect(Collectors.toList());
     }
 
     public List<Issue> findFixedIssues(Release release) throws IOException {
-        return findIssues("is not empty", release);
+        return findIssues(release).stream().filter(issue -> issue.getResolution() != null).collect(Collectors.toList());
     }
 
-    private List<Issue> findIssues(String resolution, Release release) throws IOException {
+    private void closeIssues(List<Issue> issues) throws Exception {
+        List<Promise<Issue>> closedIssues = new ArrayList<>();
+        for (Issue issue : issues) {
+            if (!"Closed".equals(issue.getStatus())) {
+                closedIssues.add(getCloseTransition(issue).then(closeTransition -> closeIssue(issue, closeTransition.getValue())));
+            }
+        }
+        Promise<List<Issue>> closedFixedIssues = promiseFactory.all(closedIssues);
+        Throwable failed = closedFixedIssues.getFailure();
+        if (failed != null) {
+            if (failed instanceof FailedPromisesException) {
+                FailedPromisesException failedPromisesException = (FailedPromisesException) failed;
+                StringBuilder failureMessages = new StringBuilder();
+                for (Promise<?> promise : failedPromisesException.getFailedPromises()) {
+                    failureMessages.append(promise.getFailure().getMessage()).append("\n");
+                }
+                throw new IOException("Unable to close the following issues:\n" + failureMessages.toString());
+            } else {
+                throw new Exception(failed);
+            }
+        }
+    }
+
+    public void release(Release release) throws Exception {
+        List<Issue> issues = findIssues(release);
+        List<Issue> unresolvedIssues = new ArrayList<>();
+        issues.forEach(issue -> {
+            if (issue.getResolution() == null) {
+                unresolvedIssues.add(issue);
+            }
+        });
+        if (unresolvedIssues.isEmpty()) {
+            closeIssues(issues);
+            Version version = find(release);
+            if (!version.isReleased()) {
+                HttpPut put = newPut("version/" + version.getId());
+                StringWriter w = new StringWriter();
+                try (JsonWriter jw = new Gson().newJsonWriter(w)) {
+                    jw.beginObject().name("released").value(true).name("releaseDate").value(dateProvider.getCurrentDateForJiraRelease())
+                            .endObject();
+                }
+                put.setEntity(new StringEntity(w.toString()));
+                try (CloseableHttpClient client = httpClientFactory.newClient()) {
+                    try (CloseableHttpResponse response = client.execute(put, httpClientFactory.newPreemptiveAuthenticationContext())) {
+                        int statusCode = response.getStatusLine().getStatusCode();
+                        if (statusCode != 200) {
+                            throw new IOException(String.format("Unable to mark %s as released. Got status code %d.", release.getFullName(),
+                                    statusCode));
+                        }
+                    }
+                }
+            } else {
+                LOGGER.info("Version {} was already released on {}.", version.getName(), version.getReleaseDate());
+            }
+        } else {
+            String report =
+                    unresolvedIssues.stream().map(issue -> String.format("%s/browse/%s", jiraURL, issue.getKey())).collect(Collectors.joining(System.lineSeparator()));
+            throw new IllegalStateException("The following issues are not fixed:\n" + report);
+        }
+    }
+
+    private List<Issue> findIssues(Release release) throws IOException {
         try {
             HttpGet get = newGet("search");
             URIBuilder builder = new URIBuilder(get.getURI());
             builder.addParameter("jql",
-                    String.format("project = %s AND resolution %s AND fixVersion = \"%s\"", PROJECT_KEY, resolution, release.getName()));
-            builder.addParameter("fields", "summary");
+                    String.format("project = %s AND fixVersion = \"%s\"", PROJECT_KEY, release.getName()));
+            builder.addParameter("fields", "summary,status,resolution");
             get.setURI(builder.build());
 
             try (CloseableHttpClient client = httpClientFactory.newClient()) {
@@ -255,12 +340,6 @@ public class VersionClient {
         }
     }
         
-    private HttpGet newGet(String suffix) {
-        HttpGet get = new HttpGet(jiraUrlPrefix + suffix);
-        get.addHeader(HttpHeaders.ACCEPT, CONTENT_TYPE_JSON);
-        return get;
-    }
-    
     private void populateRelatedIssuesCount(CloseableHttpClient client, Version version) throws IOException {
         
         HttpGet get = newGet("version/" + version.getId() +"/relatedIssueCounts");
@@ -341,4 +420,54 @@ public class VersionClient {
             throw new RuntimeException(e);
         }
     }
+
+    private Promise<Transition> getCloseTransition(Issue issue) {
+        HttpGet get = newGet("issue/" + issue.getId() + "/transitions");
+        try {
+            try (CloseableHttpClient client = httpClientFactory.newClient()) {
+                try (CloseableHttpResponse getResponse = client.execute(get, httpClientFactory.newPreemptiveAuthenticationContext())) {
+                    try (InputStream getContent = getResponse.getEntity().getContent();
+                         InputStreamReader getReader = new InputStreamReader(getContent)) {
+                        if (getResponse.getStatusLine().getStatusCode() != 200) {
+                            throw newException(getResponse, getReader);
+                        }
+                        Gson gson = new Gson();
+                        List<Transition> transitions = gson.fromJson(getReader, TransitionsResponse.class).getTransitions();
+                        Optional<Transition> transition = transitions.stream().filter(t -> "Close Issue".equals(t.getName())).findFirst();
+                        if (transition.isPresent()) {
+                            return promiseFactory.resolved(transition.get());
+                        } else {
+                            return promiseFactory
+                                    .failed(new IllegalStateException(String.format("Issue %s/browse/%s cannot be closed - missing Close " +
+                                                    "transition.", jiraURL,
+                                            issue.getKey())));
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            return promiseFactory.failed(e);
+        }
+    }
+
+    private Promise<Issue> closeIssue(Issue issue, Transition closeTransition) {
+        HttpPost post = newPost("issue/" + issue.getId() + "/transitions");
+        StringWriter w = new StringWriter();
+        try (JsonWriter jw = new Gson().newJsonWriter(w)) {
+            jw.beginObject().name("transition").beginObject().name("id").value(closeTransition.getId()).endObject().endObject();
+            post.setEntity(new StringEntity(w.toString()));
+            try (CloseableHttpClient client = httpClientFactory.newClient()) {
+                try (CloseableHttpResponse postResponse = client.execute(post, httpClientFactory.newPreemptiveAuthenticationContext())) {
+                    if (postResponse.getStatusLine().getStatusCode() == 204) {
+                        return promiseFactory.resolved(issue);
+                    } else {
+                        return promiseFactory.failed(new RuntimeException(String.format("Unable to close issue %s/browse/%s - got status code %d.",
+                         jiraURL, issue.getKey(), postResponse.getStatusLine().getStatusCode())));
+                    }
+                }
+            }
+        } catch (IOException e) {
+            return promiseFactory.failed(e);
+        }
+    }
 }
diff --git a/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java
new file mode 100644
index 0000000..cbdde72
--- /dev/null
+++ b/src/main/java/org/apache/sling/cli/impl/release/ReleaseJiraVersionCommand.java
@@ -0,0 +1,105 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements.  See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership.  The ASF licenses this file
+ ~ to you 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 org.apache.sling.cli.impl.release;
+
+import java.util.List;
+import java.util.Set;
+
+import org.apache.sling.cli.impl.Command;
+import org.apache.sling.cli.impl.ExecutionMode;
+import org.apache.sling.cli.impl.InputOption;
+import org.apache.sling.cli.impl.UserInput;
+import org.apache.sling.cli.impl.jira.Issue;
+import org.apache.sling.cli.impl.jira.VersionClient;
+import org.apache.sling.cli.impl.nexus.RepositoryService;
+import org.apache.sling.cli.impl.nexus.StagingRepository;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import picocli.CommandLine;
+
+@Component(service = Command.class,
+           property = {
+                   Command.PROPERTY_NAME_COMMAND_GROUP + "=" + ReleaseJiraVersionCommand.GROUP,
+                   Command.PROPERTY_NAME_COMMAND_NAME + "=" + ReleaseJiraVersionCommand.NAME
+           }
+)
+@CommandLine.Command(
+        name = ReleaseJiraVersionCommand.NAME,
+        description = "The found Jira versions will be marked as released with the current date. All fixed issues will be closed. Before " +
+                "running this command make sure to execute " + CreateJiraVersionCommand.NAME + " in order to move any unresolved issues " +
+                "to the next version.",
+        subcommands = CommandLine.HelpCommand.class
+)
+public class ReleaseJiraVersionCommand implements Command {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ReleaseJiraVersionCommand.class);
+
+    static final String GROUP = "release";
+    static final String NAME = "release-jira-version";
+
+    @CommandLine.Option(names = {"-r", "--repository"}, description = "Nexus repository id", required = true)
+    private Integer repositoryId;
+
+    @Reference
+    private RepositoryService repositoryService;
+
+    @Reference
+    private VersionClient versionClient;
+
+    @CommandLine.Mixin
+    private ReusableCLIOptions reusableCLIOptions;
+
+    @Override
+    public void run() {
+        try {
+            StagingRepository repo = repositoryService.find(repositoryId);
+            Set<Release> releases = repositoryService.getReleases(repo);
+            ExecutionMode executionMode = reusableCLIOptions.executionMode;
+            LOGGER.info("The following Jira versions {} be released:{}", executionMode == ExecutionMode.DRY_RUN ? "would" : "will",
+                    System.lineSeparator());
+            for (Release release : releases) {
+                List<Issue> fixedIssues = versionClient.findFixedIssues(release);
+                LOGGER.info("{}:", release.getFullName());
+                fixedIssues.forEach(issue -> LOGGER.info("- {} - {}, Status: {}, Resolution: {}", issue.getKey(), issue.getSummary(),
+                        issue.getStatus(), issue.getResolution()));
+                LOGGER.info("");
+                boolean shouldRelease = false;
+                if (executionMode == ExecutionMode.INTERACTIVE) {
+                    InputOption answer = UserInput.yesNo(String.format("Should version %s be released?", release.getFullName()),
+                            InputOption.YES);
+                    shouldRelease = (answer == InputOption.YES);
+                } else if (executionMode == ExecutionMode.AUTO) {
+                    shouldRelease = true;
+                }
+                if (shouldRelease) {
+                    versionClient.release(release);
+                    LOGGER.info("{} was released:", release.getFullName());
+                    fixedIssues = versionClient.findFixedIssues(release);
+                    fixedIssues.forEach(issue -> LOGGER.info("- {} - {}, Status: {}, Resolution: {}", issue.getKey(), issue.getSummary(),
+                            issue.getStatus(), issue.getResolution()));
+                }
+            }
+        } catch (Exception e) {
+            LOGGER.warn("Failed executing command.", e);
+        }
+    }
+}
diff --git a/src/test/java/org/apache/sling/cli/impl/http/HttpExchangeHandler.java b/src/test/java/org/apache/sling/cli/impl/http/HttpExchangeHandler.java
index 660d34c..53bcc93 100644
--- a/src/test/java/org/apache/sling/cli/impl/http/HttpExchangeHandler.java
+++ b/src/test/java/org/apache/sling/cli/impl/http/HttpExchangeHandler.java
@@ -29,15 +29,16 @@ import com.sun.net.httpserver.HttpExchange;
 public interface HttpExchangeHandler {
 
     default void serveFileFromClasspath(HttpExchange ex, String classpathLocation) throws IOException {
-        InputStream in = getClass().getResourceAsStream(classpathLocation);
-        if ( in == null  ) {
-            ex.sendResponseHeaders(404, -1);
-            return;
-        }
-
-        ex.sendResponseHeaders(200, 0);
-        try ( OutputStream out = ex.getResponseBody() ) {
-            IOUtils.copy(in, out);
+        try (InputStream in = getClass().getResourceAsStream(classpathLocation)) {
+            if (in == null) {
+                ex.sendResponseHeaders(404, -1);
+                return;
+            }
+
+            ex.sendResponseHeaders(200, 0);
+            try (OutputStream out = ex.getResponseBody()) {
+                IOUtils.copy(in, out);
+            }
         }
     }
 
diff --git a/src/test/java/org/apache/sling/cli/impl/jira/EditVersionJiraAction.java b/src/test/java/org/apache/sling/cli/impl/jira/EditVersionJiraAction.java
new file mode 100644
index 0000000..918bf32
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/jira/EditVersionJiraAction.java
@@ -0,0 +1,103 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements.  See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership.  The ASF licenses this file
+ ~ to you 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 org.apache.sling.cli.impl.jira;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+import org.apache.sling.cli.impl.DateProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.sun.net.httpserver.HttpExchange;
+
+public class EditVersionJiraAction implements JiraAction {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(EditVersionJiraAction.class);
+    private static final Pattern VERSION_ID = Pattern.compile("/jira/rest/api/2/version/\\d+$");
+    private static final String ALREADY_RELEASED = "/jira/rest/api/2/version/1";
+
+    @Override
+    public boolean tryHandle(HttpExchange ex) throws IOException {
+        if (!VERSION_ID.matcher(ex.getRequestURI().getPath()).matches()) {
+            return false;
+        }
+        if (ALREADY_RELEASED.equals(ex.getRequestURI().getPath())) {
+            // make sure we cannot update an already released version
+            ex.sendResponseHeaders(500, -1);
+        }
+        String versionIdString = ex.getRequestURI().getPath().substring(25);
+        try {
+            int versionId = Integer.parseInt(versionIdString);
+            try (InputStream in = getClass().getResourceAsStream("/jira/versions.json")) {
+                if (in == null) {
+                    ex.sendResponseHeaders(404, -1);
+                    return true;
+                }
+                try (InputStreamReader reader = new InputStreamReader(in)) {
+                    Gson gson = new Gson();
+                    Type collectionType = TypeToken.getParameterized(List.class, Version.class).getType();
+                    List<Version> versions = gson.fromJson(reader, collectionType);
+                    Optional<Version> versionHolder = versions.stream().filter(v -> versionId == v.getId()).findFirst();
+                    if (versionHolder.isEmpty()) {
+                        ex.sendResponseHeaders(404, -1);
+                    } else {
+                        if ("PUT".equals(ex.getRequestMethod())) {
+                            // version change
+                            Version version = versionHolder.get();
+                            try (InputStreamReader requestReader = new InputStreamReader(ex.getRequestBody())) {
+                                VersionToUpdate versionToUpdate = gson.fromJson(requestReader, VersionToUpdate.class);
+                                DateProvider dateProvider = new DateProvider();
+                                if (versionToUpdate.released != version.isReleased() && versionToUpdate.released &&
+                                        dateProvider.getCurrentDateForJiraRelease().equals(versionToUpdate.releaseDate) &&
+                                        version.getReleaseDate() == null) {
+                                    ex.sendResponseHeaders(200, -1);
+                                }
+                            }
+                        }
+                        ex.sendResponseHeaders(406, -1);
+                    }
+                }
+            }
+
+        } catch (NumberFormatException e) {
+            LOGGER.error("Unable to parse version id from " + ex.getRequestURI().getPath(), e);
+            ex.sendResponseHeaders(400, -1);
+        }
+        return true;
+    }
+
+    @SuppressWarnings("unused")
+    static class VersionToUpdate {
+        private String description;
+        private String name;
+        private boolean archived;
+        private boolean released;
+        private String releaseDate;
+        private String userReleaseDate;
+        private String projectId;
+    }
+}
diff --git a/src/test/java/org/apache/sling/cli/impl/jira/IssuesSearchJiraAction.java b/src/test/java/org/apache/sling/cli/impl/jira/IssuesSearchJiraAction.java
index 13bed5d..896a878 100644
--- a/src/test/java/org/apache/sling/cli/impl/jira/IssuesSearchJiraAction.java
+++ b/src/test/java/org/apache/sling/cli/impl/jira/IssuesSearchJiraAction.java
@@ -28,8 +28,10 @@ import com.sun.net.httpserver.HttpExchange;
 
 public class IssuesSearchJiraAction implements JiraAction {
     
-    private static final String UNRESOLVED_QUERY = "project = SLING AND resolution is empty AND fixVersion = \"Committer CLI 1.0.0\"";
-    private static final String FIXED_QUERY = "project = SLING AND resolution is not empty AND fixVersion = \"Committer CLI 1.0.0\"";
+    private static final String COMMITTER_CLI_1_0_0_QUERY = "project = SLING AND fixVersion = \"Committer CLI 1.0.0\"";
+    private static final String TRANSITIONS_0_1_0_QUERY = "project = SLING AND fixVersion = \"Transitions 0.1.0\"";
+    private static final String TRANSITIONS_1_0_0_QUERY = "project = SLING AND fixVersion = \"Transitions 1.0.0\"";
+    private static final String TRANSITIONS_2_0_0_QUERY = "project = SLING AND fixVersion = \"Transitions 2.0.0\"";
 
     @Override
     public boolean tryHandle(HttpExchange ex) throws IOException {
@@ -42,17 +44,23 @@ public class IssuesSearchJiraAction implements JiraAction {
         
         for ( NameValuePair pair : parsed ) {
             if ( "jql".equals(pair.getName())) {
-                if (UNRESOLVED_QUERY.equals(pair.getValue())) {
-                    serveFileFromClasspath(ex, "/jira/search/unresolved-committer-cli-1.0.0.json");
+                if (COMMITTER_CLI_1_0_0_QUERY.equals(pair.getValue())) {
+                    serveFileFromClasspath(ex, "/jira/search/committer-cli-1.0.0.json");
                     return true;
-                } else if (FIXED_QUERY.equals(pair.getValue())) {
-                    serveFileFromClasspath(ex, "/jira/search/fixed-committer-cli-1.0.0.json");
+                } else if (TRANSITIONS_0_1_0_QUERY.equals(pair.getValue())) {
+                    serveFileFromClasspath(ex, "/jira/search/transitions-0.1.0.json");
+                    return true;
+                } else if (TRANSITIONS_1_0_0_QUERY.equals(pair.getValue())) {
+                    serveFileFromClasspath(ex, "/jira/search/transitions-1.0.0.json");
+                    return true;
+                } else if (TRANSITIONS_2_0_0_QUERY.equals(pair.getValue())) {
+                    serveFileFromClasspath(ex, "/jira/search/transitions-2.0.0.json");
                     return true;
                 }
             }
         }
         error(ex, new Gson(), er -> er.getErrorMessages().add("Unable to run unknown JQL query, available ones are [" +
-                UNRESOLVED_QUERY + "," + FIXED_QUERY +"]"));
+                COMMITTER_CLI_1_0_0_QUERY + "," + TRANSITIONS_1_0_0_QUERY +"]"));
         
         return true;
     }
diff --git a/src/test/java/org/apache/sling/cli/impl/jira/MockJira.java b/src/test/java/org/apache/sling/cli/impl/jira/MockJira.java
index 73ccb5b..393f577 100644
--- a/src/test/java/org/apache/sling/cli/impl/jira/MockJira.java
+++ b/src/test/java/org/apache/sling/cli/impl/jira/MockJira.java
@@ -19,6 +19,8 @@ package org.apache.sling.cli.impl.jira;
 import java.net.InetSocketAddress;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
 
 import org.apache.sling.cli.impl.http.HttpExchangeHandler;
 import org.junit.rules.ExternalResource;
@@ -34,6 +36,7 @@ public class MockJira extends ExternalResource {
     
     static final String AUTH_USER = "asf-user";
     static final String AUTH_PWD = "asf-password";
+    private static final Set<Pattern> GET_PATHS_REQUIRING_AUTH = Set.of(Pattern.compile("/jira/rest/api/2/issue/\\d+/transitions"));
     
     public static void main(String[] args) throws Throwable {
         
@@ -57,9 +60,14 @@ public class MockJira extends ExternalResource {
             
             @Override
             public Result authenticate(HttpExchange t) {
-                // get requests are never authenticated
-                if ( t.getRequestMethod().contentEquals("GET") )
+                if ("GET".equals(t.getRequestMethod())) {
+                    for (Pattern pathPattern : GET_PATHS_REQUIRING_AUTH) {
+                        if (pathPattern.matcher(t.getRequestURI().getPath()).matches()) {
+                            return super.authenticate(t);
+                        }
+                    }
                     return new Authenticator.Success(new HttpPrincipal("anonymous", getClass().getSimpleName()));
+                }
                 return super.authenticate(t);
             }
         });
@@ -69,7 +77,9 @@ public class MockJira extends ExternalResource {
         actions.add(new GetRelatedIssueCountsForVersionsJiraAction());
         actions.add(new CreateVersionJiraAction());
         actions.add(new IssuesSearchJiraAction());
-        
+        actions.add(new TransitionsJiraAction());
+        actions.add(new EditVersionJiraAction());
+
         // fallback, always executed
         actions.add(ex -> {
             ex.sendResponseHeaders(400, -1);
diff --git a/src/test/java/org/apache/sling/cli/impl/jira/TransitionsJiraAction.java b/src/test/java/org/apache/sling/cli/impl/jira/TransitionsJiraAction.java
new file mode 100644
index 0000000..314c698
--- /dev/null
+++ b/src/test/java/org/apache/sling/cli/impl/jira/TransitionsJiraAction.java
@@ -0,0 +1,66 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements.  See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership.  The ASF licenses this file
+ ~ to you 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 org.apache.sling.cli.impl.jira;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.regex.Pattern;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import com.sun.net.httpserver.HttpExchange;
+
+public class TransitionsJiraAction implements JiraAction {
+
+    private static final Pattern TRANSITIONS = Pattern.compile("/jira/rest/api/2/issue/\\d+/transitions");
+    private static final Pattern RETURN_NO_TRANSITIONS = Pattern.compile("/jira/rest/api/2/issue/(1|2|3|4)/transitions");
+
+    @Override
+    public boolean tryHandle(HttpExchange ex) throws IOException {
+        if (!TRANSITIONS.matcher(ex.getRequestURI().getPath()).matches()) {
+            return false;
+        }
+        if (ex.getRequestMethod().equals("GET")) {
+            if (RETURN_NO_TRANSITIONS.matcher(ex.getRequestURI().getPath()).matches()) {
+                serveFileFromClasspath(ex, "/jira/transitions/no-transitions.json");
+            } else {
+                serveFileFromClasspath(ex, "/jira/transitions/transitions.json");
+            }
+        } else if (ex.getRequestMethod().equals("POST")) {
+            Gson gson = new Gson();
+            try ( InputStreamReader reader = new InputStreamReader(ex.getRequestBody())) {
+                TransitionToExecute transitionToExecute = gson.fromJson(reader, TransitionToExecute.class);
+                if (701 == transitionToExecute.transition.getId()) {
+                    ex.sendResponseHeaders(204, -1);
+                } else {
+                    ex.sendResponseHeaders(400, -1);
+                }
+            } catch (JsonParseException e) {
+                ex.sendResponseHeaders(400, -1);
+            }
+        }
+        return true;
+    }
+
+    private static class TransitionToExecute {
+       private Transition transition;
+    }
+
+
+}
diff --git a/src/test/java/org/apache/sling/cli/impl/jira/VersionClientTest.java b/src/test/java/org/apache/sling/cli/impl/jira/VersionClientTest.java
index 5ad42f8..a0607ea 100644
--- a/src/test/java/org/apache/sling/cli/impl/jira/VersionClientTest.java
+++ b/src/test/java/org/apache/sling/cli/impl/jira/VersionClientTest.java
@@ -16,18 +16,13 @@
  */
 package org.apache.sling.cli.impl.jira;
 
-import static org.hamcrest.Matchers.equalTo;
-import static org.hamcrest.Matchers.hasSize;
-import static org.hamcrest.Matchers.notNullValue;
-import static org.hamcrest.Matchers.nullValue;
-import static org.junit.Assert.assertThat;
-
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 import org.apache.sling.cli.impl.CredentialsService;
+import org.apache.sling.cli.impl.DateProvider;
 import org.apache.sling.cli.impl.http.HttpClientFactory;
 import org.apache.sling.cli.impl.junit.SystemPropertiesRule;
 import org.apache.sling.cli.impl.release.Release;
@@ -36,6 +31,15 @@ import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+
 public class VersionClientTest {
 
     private static final Map<String, String> SYSTEM_PROPS = new HashMap<>();
@@ -52,15 +56,15 @@ public class VersionClientTest {
     
     @Rule
     public final MockJira mockJira = new MockJira();
-    
+
     private VersionClient versionClient;
     
     @Before
     public void prepareDependencies() {
-        
         context.registerInjectActivateService(new CredentialsService());
+        context.registerInjectActivateService(new DateProvider());
         context.registerInjectActivateService(new HttpClientFactory(), "jira.host", "localhost", "jira.port", mockJira.getBoundPort());
-        versionClient = context.registerInjectActivateService(new VersionClient(), "jira.url.prefix", "http://localhost:" + mockJira.getBoundPort() + "/jira/rest/api/2/");
+        versionClient = context.registerInjectActivateService(new VersionClient(), "jira.url", "http://localhost:" + mockJira.getBoundPort() + "/jira");
     }
     
     @Test
@@ -111,7 +115,9 @@ public class VersionClientTest {
         
         assertThat(issues, hasSize(2));
         assertThat(issues.get(0).getKey(), equalTo("SLING-8338"));
+        assertThat(issues.get(0).getStatus(), equalTo("Open"));
         assertThat(issues.get(1).getKey(), equalTo("SLING-8337"));
+        assertThat(issues.get(1).getStatus(), equalTo("Open"));
     }
 
     @Test
@@ -120,11 +126,57 @@ public class VersionClientTest {
 
         assertThat(issues, hasSize(7));
         assertThat(issues.get(0).getKey(), equalTo("SLING-8707"));
+        assertThat(issues.get(0).getStatus(), equalTo("Resolved"));
         assertThat(issues.get(1).getKey(), equalTo("SLING-8699"));
+        assertThat(issues.get(1).getStatus(), equalTo("Resolved"));
         assertThat(issues.get(2).getKey(), equalTo("SLING-8395"));
+        assertThat(issues.get(2).getStatus(), equalTo("Resolved"));
         assertThat(issues.get(3).getKey(), equalTo("SLING-8394"));
+        assertThat(issues.get(3).getStatus(), equalTo("Resolved"));
         assertThat(issues.get(4).getKey(), equalTo("SLING-8393"));
+        assertThat(issues.get(4).getStatus(), equalTo("Resolved"));
         assertThat(issues.get(5).getKey(), equalTo("SLING-8392"));
+        assertThat(issues.get(5).getStatus(), equalTo("Resolved"));
         assertThat(issues.get(6).getKey(), equalTo("SLING-8338"));
+        assertThat(issues.get(6).getStatus(), equalTo("Resolved"));
+    }
+
+    @Test
+    public void releaseWithUnresolvedIssues() {
+        Release release = Release.fromString("Committer CLI 1.0.0").get(0);
+        Exception exception = null;
+        try {
+            versionClient.release(release);
+        } catch (Exception e) {
+            exception = e;
+        }
+        assertNotNull("The VersionClient should not have allowed a release with unresolved issues.", exception);
+        assertTrue("SLING-8337 should have been reported as unresolved.", exception.getMessage().contains("SLING-8337"));
+        assertTrue("SLING-8338 should have been reported as unresolved.", exception.getMessage().contains("SLING-8338"));
+    }
+
+    @Test
+    public void release() {
+        Exception exception = null;
+        try {
+            Release release = Release.fromString("Transitions 2.0.0").get(0);
+            versionClient.release(release);
+        } catch (Exception e) {
+            exception = e;
+        }
+        assertNull("Marking Transitions 2.0.0 as released should have worked.", exception);
+    }
+
+    @Test
+    public void releaseAlreadyReleasedVersion() {
+        Release release = Release.fromString("Transitions 0.1.0").get(0);
+        Throwable throwable = null;
+        try {
+            versionClient.release(release);
+        } catch (Exception e) {
+            throwable = e;
+        }
+        assertNull("Did not expect an error, since this case should be handled graciously.", throwable);
     }
+
 }
diff --git a/src/test/resources/jira/relatedIssueCounts/0.json b/src/test/resources/jira/relatedIssueCounts/0.json
new file mode 100644
index 0000000..c4cfeb5
--- /dev/null
+++ b/src/test/resources/jira/relatedIssueCounts/0.json
@@ -0,0 +1,6 @@
+{
+    "self": "https://issues.apache.org/jira/rest/api/2/version/0",
+    "issuesFixedCount": 3,
+    "issuesAffectedCount": 3,
+    "issueCountWithCustomFieldsShowingVersion": 0
+}
diff --git a/src/test/resources/jira/relatedIssueCounts/1.json b/src/test/resources/jira/relatedIssueCounts/1.json
new file mode 100644
index 0000000..21eb5ff
--- /dev/null
+++ b/src/test/resources/jira/relatedIssueCounts/1.json
@@ -0,0 +1,6 @@
+{
+    "self": "https://issues.apache.org/jira/rest/api/2/version/1",
+    "issuesFixedCount": 0,
+    "issuesAffectedCount": 0,
+    "issueCountWithCustomFieldsShowingVersion": 0
+}
diff --git a/src/test/resources/jira/search/fixed-committer-cli-1.0.0.json b/src/test/resources/jira/search/committer-cli-1.0.0.json
similarity index 50%
rename from src/test/resources/jira/search/fixed-committer-cli-1.0.0.json
rename to src/test/resources/jira/search/committer-cli-1.0.0.json
index dbd6685..84916c5 100644
--- a/src/test/resources/jira/search/fixed-committer-cli-1.0.0.json
+++ b/src/test/resources/jira/search/committer-cli-1.0.0.json
@@ -2,7 +2,7 @@
     "expand": "schema,names",
     "startAt": 0,
     "maxResults": 50,
-    "total": 7,
+    "total": 9,
     "issues": [
         {
             "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
@@ -10,7 +10,13 @@
             "self": "https://issues.apache.org/jira/rest/api/2/issue/13256564",
             "key": "SLING-8707",
             "fields": {
-                "summary": "Try to map a staging Nexus repository to a JIRA release by inspecting the POM files"
+                "summary": "Try to map a staging Nexus repository to a JIRA release by inspecting the POM files",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
             }
         },
         {
@@ -19,7 +25,13 @@
             "self": "https://issues.apache.org/jira/rest/api/2/issue/13256303",
             "key": "SLING-8699",
             "fields": {
-                "summary": "Create sub-command to moving artifacts to dist.apache.org"
+                "summary": "Create sub-command to moving artifacts to dist.apache.org",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
             }
         },
         {
@@ -28,7 +40,13 @@
             "self": "https://issues.apache.org/jira/rest/api/2/issue/13231466",
             "key": "SLING-8395",
             "fields": {
-                "summary": "Investigate automatically issuing GitHub PRs with the Committer CLI"
+                "summary": "Investigate automatically issuing GitHub PRs with the Committer CLI",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
             }
         },
         {
@@ -37,7 +55,13 @@
             "self": "https://issues.apache.org/jira/rest/api/2/issue/13231465",
             "key": "SLING-8394",
             "fields": {
-                "summary": "Create sub-command to update the Sling starter when a release is made"
+                "summary": "Create sub-command to update the Sling starter when a release is made",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
             }
         },
         {
@@ -46,7 +70,13 @@
             "self": "https://issues.apache.org/jira/rest/api/2/issue/13231464",
             "key": "SLING-8393",
             "fields": {
-                "summary": "Create sub-command to update the Sling website when a release is made"
+                "summary": "Create sub-command to update the Sling website when a release is made",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
             }
         },
         {
@@ -55,7 +85,13 @@
             "self": "https://issues.apache.org/jira/rest/api/2/issue/13231459",
             "key": "SLING-8392",
             "fields": {
-                "summary": "Create sub-command to manage the Jira update when promoting a release"
+                "summary": "Create sub-command to manage the Jira update when promoting a release",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
             }
         },
         {
@@ -64,7 +100,39 @@
             "self": "https://issues.apache.org/jira/rest/api/2/issue/13225243",
             "key": "SLING-8338",
             "fields": {
-                "summary": "Create sub-command to manage the Nexus stage repository release when promoting a release"
+                "summary": "Create sub-command to manage the Nexus stage repository release when promoting a release",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
+            }
+        },
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "13225243",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/13225243",
+            "key": "SLING-8338",
+            "fields": {
+                "summary": "Create sub-command to manage the Nexus stage repository release when promoting a release",
+                "status": {
+                    "name": "Open"
+                },
+                "resolution": null
+            }
+        },
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "13224868",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/13224868",
+            "key": "SLING-8337",
+            "fields": {
+                "summary": "Create sub-command to manage the Jira update when promoting a release",
+                "status": {
+                    "name": "Open"
+                },
+                "resolution": null
             }
         }
     ]
diff --git a/src/test/resources/jira/search/transitions-0.1.0.json b/src/test/resources/jira/search/transitions-0.1.0.json
new file mode 100644
index 0000000..28c8b97
--- /dev/null
+++ b/src/test/resources/jira/search/transitions-0.1.0.json
@@ -0,0 +1,8 @@
+{
+    "expand": "schema,names",
+    "startAt": 0,
+    "maxResults": 50,
+    "total": 0,
+    "issues": [
+    ]
+}
diff --git a/src/test/resources/jira/search/transitions-1.0.0.json b/src/test/resources/jira/search/transitions-1.0.0.json
new file mode 100644
index 0000000..bb61a81
--- /dev/null
+++ b/src/test/resources/jira/search/transitions-1.0.0.json
@@ -0,0 +1,53 @@
+{
+    "expand": "schema,names",
+    "startAt": 0,
+    "maxResults": 50,
+    "total": 3,
+    "issues": [
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "1",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/1",
+            "key": "SLING-0001",
+            "fields": {
+                "summary": "Test Transitions 1",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
+            }
+        },
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "2",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/2",
+            "key": "SLING-0002",
+            "fields": {
+                "summary": "Test Transitions 2",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
+            }
+        },
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "3",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/3",
+            "key": "SLING-0003",
+            "fields": {
+                "summary": "Test Transitions 3",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
+            }
+        }
+    ]
+}
diff --git a/src/test/resources/jira/search/transitions-2.0.0.json b/src/test/resources/jira/search/transitions-2.0.0.json
new file mode 100644
index 0000000..153ad01
--- /dev/null
+++ b/src/test/resources/jira/search/transitions-2.0.0.json
@@ -0,0 +1,53 @@
+{
+    "expand": "schema,names",
+    "startAt": 0,
+    "maxResults": 50,
+    "total": 3,
+    "issues": [
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "4",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/4",
+            "key": "SLING-0004",
+            "fields": {
+                "summary": "Test Transitions 4",
+                "status": {
+                    "name": "Closed"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
+            }
+        },
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "5",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/5",
+            "key": "SLING-0005",
+            "fields": {
+                "summary": "Test Transitions 5",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
+            }
+        },
+        {
+            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
+            "id": "6",
+            "self": "https://issues.apache.org/jira/rest/api/2/issue/6",
+            "key": "SLING-0006",
+            "fields": {
+                "summary": "Test Transitions 6",
+                "status": {
+                    "name": "Resolved"
+                },
+                "resolution": {
+                    "name": "Fixed"
+                }
+            }
+        }
+    ]
+}
diff --git a/src/test/resources/jira/search/unresolved-committer-cli-1.0.0.json b/src/test/resources/jira/search/unresolved-committer-cli-1.0.0.json
deleted file mode 100644
index 6a01267..0000000
--- a/src/test/resources/jira/search/unresolved-committer-cli-1.0.0.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-  "expand": "schema,names",
-  "startAt": 0,
-  "maxResults": 50,
-  "total": 2,
-  "issues": [
-    {
-      "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
-      "id": "13225243",
-      "self": "https://issues.apache.org/jira/rest/api/2/issue/13225243",
-      "key": "SLING-8338",
-      "fields": {
-        "summary": "Create sub-command to manage the Nexus stage repository release when promoting a release"
-      }
-    },
-    {
-      "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
-      "id": "13224868",
-      "self": "https://issues.apache.org/jira/rest/api/2/issue/13224868",
-      "key": "SLING-8337",
-      "fields": {
-        "summary": "Create sub-command to manage the Jira update when promoting a release"
-      }
-    }
-  ]
-}
diff --git a/src/test/resources/jira/transitions/no-transitions.json b/src/test/resources/jira/transitions/no-transitions.json
new file mode 100644
index 0000000..496bc81
--- /dev/null
+++ b/src/test/resources/jira/transitions/no-transitions.json
@@ -0,0 +1,5 @@
+{
+    "expand"     : "transitions",
+    "transitions": [
+    ]
+}
diff --git a/src/test/resources/jira/transitions/transitions.json b/src/test/resources/jira/transitions/transitions.json
new file mode 100644
index 0000000..950f412
--- /dev/null
+++ b/src/test/resources/jira/transitions/transitions.json
@@ -0,0 +1,41 @@
+{
+    "expand"     : "transitions",
+    "transitions": [
+        {
+            "id"  : "701",
+            "name": "Close Issue",
+            "to"  : {
+                "self"          : "https://issues.apache.org/jira/rest/api/2/status/6",
+                "description"   : "The issue is considered finished, the resolution is correct. Issues which are not closed can be reopened.",
+                "iconUrl"       : "https://issues.apache.org/jira/images/icons/statuses/closed.png",
+                "name"          : "Closed",
+                "id"            : "6",
+                "statusCategory": {
+                    "self"     : "https://issues.apache.org/jira/rest/api/2/statuscategory/3",
+                    "id"       : 3,
+                    "key"      : "done",
+                    "colorName": "green",
+                    "name"     : "Done"
+                }
+            }
+        },
+        {
+            "id"  : "3",
+            "name": "Reopen Issue",
+            "to"  : {
+                "self"          : "https://issues.apache.org/jira/rest/api/2/status/4",
+                "description"   : "This issue was once resolved, but the resolution was deemed incorrect. From here issues are either marked assigned or resolved.",
+                "iconUrl"       : "https://issues.apache.org/jira/images/icons/statuses/reopened.png",
+                "name"          : "Reopened",
+                "id"            : "4",
+                "statusCategory": {
+                    "self"     : "https://issues.apache.org/jira/rest/api/2/statuscategory/2",
+                    "id"       : 2,
+                    "key"      : "new",
+                    "colorName": "blue-gray",
+                    "name"     : "To Do"
+                }
+            }
+        }
+    ]
+}
diff --git a/src/test/resources/jira/versions.json b/src/test/resources/jira/versions.json
index d7e5226..401e839 100644
--- a/src/test/resources/jira/versions.json
+++ b/src/test/resources/jira/versions.json
@@ -2083,5 +2083,27 @@
     "releaseDate": "2010-12-13",
     "userReleaseDate": "13/Dec/10",
     "projectId": 12310710
+  },
+  {
+    "self": "https://issues.apache.org/jira/rest/api/2/version/0",
+    "id": "0",
+    "description": "Maintenance release",
+    "name": "Transitions 2.0.0",
+    "archived": false,
+    "released": false,
+    "releaseDate": null,
+    "userReleaseDate": null,
+    "projectId": 12310710
+  },
+  {
+    "self": "https://issues.apache.org/jira/rest/api/2/version/1",
+    "id": "1",
+    "description": "Maintenance release",
+    "name": "Transitions 0.1.0",
+    "archived": false,
+    "released": true,
+    "releaseDate": "2019-12-16",
+    "userReleaseDate": null,
+    "projectId": 12310710
   }
 ]