blob: 73b0dba726649f8dcb71567aa299d612fc3c0f24 [file] [log] [blame]
/*
* 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.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;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
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;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonWriter;
/**
* Access the ASF <em>Jira</em> instance and looks up project version data.
*/
@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 = "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;
@Reference
private DateProvider dateProvider;
private String jiraRESTAPIEntrypoint;
private String jiraURL;
@Activate
protected void activate(ComponentContext ctx) {
ComponentContextHelper helper = ComponentContextHelper.wrap(ctx);
jiraURL = helper.getProperty("jira.url", DEFAULT_JIRA_URL);
jiraRESTAPIEntrypoint = jiraURL + "/rest/api/2/";
}
/**
* Finds a Jira version which matches the specified release
*
* @param release the release
* @return the version
* @throws IllegalArgumentException when no matching Jira release is found
*/
public Version find(Release release) {
Version version;
try (CloseableHttpClient client = httpClientFactory.newClient()) {
version = findVersion( v -> release.getName().equals(v.getName()), client)
.orElseThrow( () -> new IllegalArgumentException("No version found with name " + release.getName()));
populateRelatedIssuesCount(client, version);
} catch ( IOException e ) {
throw new RuntimeException(e);
}
return version;
}
/**
* Finds a version that is the successor of the version of the specified
* <tt>release</tt>
*
* <p>
* A successor has the same base name but a higher version. For instance, the
* <em>XSS Protection API 2.1.6</em> is succeeded by <em>XSS Protection API
* 2.1.8</em>.
* </p>
*
* <p>
* If multiple successors are found the one which is closest in terms of
* versioning is returned.
* </p>
*
* @param release the release to find a successor for
* @return the successor version, possibly <code>null</code>
*/
public Version findSuccessorVersion(Release release) {
Version version;
try (CloseableHttpClient client = httpClientFactory.newClient()) {
Optional<Version> opt = findVersion (
v -> isFollowingVersion(Release.fromString(v.getName()).get(0), release)
,client);
if (opt.isEmpty())
return null;
version = opt.get();
populateRelatedIssuesCount(client, version);
} catch ( IOException e ) {
throw new RuntimeException(e);
}
return version;
}
/**
* Creates a version with the specified name
*
* <p>The version will be created for the {@value #PROJECT_KEY} project.</p>
*
* @param versionName the name of the version
* @throws IOException In case of any errors creating the version in Jira
*/
public void create(String versionName) throws IOException {
StringWriter w = new StringWriter();
try ( JsonWriter jw = new Gson().newJsonWriter(w) ) {
jw.beginObject();
jw.name("name").value(versionName);
jw.name("project").value(PROJECT_KEY);
jw.endObject();
}
HttpPost post = newPost("version");
post.setEntity(new StringEntity(w.toString()));
try (CloseableHttpClient client = httpClientFactory.newClient()) {
try (CloseableHttpResponse response = client.execute(post, httpClientFactory.newPreemptiveAuthenticationContext())) {
try (InputStream content = response.getEntity().getContent();
InputStreamReader reader = new InputStreamReader(content)) {
if (response.getStatusLine().getStatusCode() != 201) {
throw newException(response, reader);
}
}
}
}
}
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(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(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(release).stream().filter(issue -> issue.getResolution() == null).collect(Collectors.toList());
}
public List<Issue> findFixedIssues(Release release) throws IOException {
return findIssues(release).stream().filter(issue -> issue.getResolution() != null).collect(Collectors.toList());
}
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 fixVersion = \"%s\"", PROJECT_KEY, release.getName()));
builder.addParameter("fields", "summary,status,resolution");
get.setURI(builder.build());
try (CloseableHttpClient client = httpClientFactory.newClient()) {
try (CloseableHttpResponse response = client.execute(get)) {
try (InputStream content = response.getEntity().getContent();
InputStreamReader reader = new InputStreamReader(content)) {
if (response.getStatusLine().getStatusCode() != 200) {
throw newException(response, reader);
}
Gson gson = new Gson();
return gson.fromJson(reader, IssueResponse.class).getIssues();
}
}
}
} catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
}
private IOException newException(CloseableHttpResponse response, InputStreamReader reader) {
StringBuilder message = new StringBuilder();
message.append("Status line : ").append(response.getStatusLine());
try {
Gson gson = new Gson();
ErrorResponse errors = gson.fromJson(reader, ErrorResponse.class);
if ( errors != null ) {
if ( !errors.getErrorMessages().isEmpty() )
message.append(". Error messages: ")
.append(errors.getErrorMessages());
if ( !errors.getErrors().isEmpty() )
errors.getErrors().forEach((key, value) -> message.append(". Error for ").append(key).append(" : ").append(value));
}
} catch ( JsonIOException | JsonSyntaxException e) {
message.append(". Failed parsing response as JSON ( ")
.append(e.getMessage())
.append(" )");
}
return new IOException(message.toString());
}
private Optional<Version> findVersion(Predicate<Version> matcher, CloseableHttpClient client) throws IOException {
HttpGet get = newGet("project/" + PROJECT_KEY + "/versions");
try (CloseableHttpResponse response = client.execute(get)) {
try (InputStream content = response.getEntity().getContent();
InputStreamReader reader = new InputStreamReader(content)) {
if (response.getStatusLine().getStatusCode() != 200)
throw newException(response, reader);
Gson gson = new Gson();
Type collectionType = TypeToken.getParameterized(List.class, Version.class).getType();
List<Version> versions = gson.fromJson(reader, collectionType);
return versions.stream()
.filter( v -> v.getName().length() > 1) // avoid old '3' release
.filter(matcher)
.min(VersionClient::compare);
}
}
}
private void populateRelatedIssuesCount(CloseableHttpClient client, Version version) throws IOException {
HttpGet get = newGet("version/" + version.getId() +"/relatedIssueCounts");
try (CloseableHttpResponse response = client.execute(get)) {
try (InputStream content = response.getEntity().getContent();
InputStreamReader reader = new InputStreamReader(content)) {
if (response.getStatusLine().getStatusCode() != 200)
throw newException(response, reader);
Gson gson = new Gson();
VersionRelatedIssuesCount issuesCount = gson.fromJson(reader, VersionRelatedIssuesCount.class);
version.setRelatedIssuesCount(issuesCount.getIssuesFixedCount());
}
}
}
private boolean isFollowingVersion(Release base, Release candidate) {
return base.getComponent().equals(candidate.getComponent())
&& new org.osgi.framework.Version(base.getVersion())
.compareTo(new org.osgi.framework.Version(candidate.getVersion())) > 0;
}
private static int compare(Version v1, Version v2) {
// version names will never map to multiple release names
Release r1 = Release.fromString(v1.getName()).get(0);
Release r2 = Release.fromString(v2.getName()).get(0);
org.osgi.framework.Version ver1 = new org.osgi.framework.Version(r1.getVersion());
org.osgi.framework.Version ver2 = new org.osgi.framework.Version(r2.getVersion());
return ver1.compareTo(ver2);
}
static class VersionRelatedIssuesCount {
private int issuesFixedCount;
public int getIssuesFixedCount() {
return issuesFixedCount;
}
@SuppressWarnings("unused")
public void setIssuesFixedCount(int issuesFixedCount) {
this.issuesFixedCount = issuesFixedCount;
}
}
public void moveIssuesToNewVersion(Version oldVersion, Version newVersion, List<Issue> issues) {
issues.forEach( i -> moveIssueToNewVersion(oldVersion, newVersion, i));
}
private void moveIssueToNewVersion(Version oldVersion, Version newVersion, Issue issue) {
try {
StringWriter w = new StringWriter();
IssueUpdate update = new IssueUpdate();
update.recordAdd("fixVersions", newVersion.getName());
update.recordRemove("fixVersions", oldVersion.getName());
Gson gson = new Gson();
gson.toJson(update, w);
HttpPut put = newPut("issue/" + issue.getKey());
put.setEntity(new StringEntity(w.toString(), StandardCharsets.UTF_8));
try (CloseableHttpClient client = httpClientFactory.newClient()) {
try (CloseableHttpResponse response = client.execute(put, httpClientFactory.newPreemptiveAuthenticationContext())) {
if (response.getStatusLine().getStatusCode() != 204) {
try (InputStream content = response.getEntity().getContent();
InputStreamReader reader = new InputStreamReader(content)) {
throw newException(response, reader);
}
}
}
}
} catch (IOException e) {
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);
}
}
}