#94 - Extend IssueTracker.closeIteration(…) to resolve release tickets.

closeIteration() now resolves release tickets within its release version to simplify post-release tasks.
This commit is contained in:
Mark Paluch
2018-12-11 09:48:18 +01:00
parent e066093976
commit e51c06c8e3
7 changed files with 244 additions and 22 deletions

View File

@@ -23,6 +23,10 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -44,6 +48,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.client.HttpStatusCodeException;
@@ -308,6 +313,31 @@ class GitHub implements IssueTracker {
return getReleaseTicketFor(module);
}
/**
* Close the release ticket.
*
* @param module
* @return
*/
public Ticket closeReleaseTicket(ModuleIteration module) {
Assert.notNull(module, "ModuleIteration must not be null.");
Ticket releaseTicketFor = getReleaseTicketFor(module);
String repositoryName = GitProject.of(module.getProject()).getRepositoryName();
Map<String, Object> parameters = newUrlTemplateVariables();
parameters.put("repoName", repositoryName);
parameters.put("id", stripHash(releaseTicketFor));
GitHubIssue edit = GitHubIssue.assignedTo(properties.getUsername()).close();
GitHubIssue response = operations.exchange(ISSUE_BY_ID_URI_TEMPLATE, HttpMethod.PATCH,
new HttpEntity<>(edit, newUserScopedHttpHeaders()), ISSUE_TYPE, parameters).getBody();
return toTicket(response);
}
private String stripHash(Ticket ticket) {
return ticket.getId().startsWith("#") ? ticket.getId().substring(1) : ticket.getId();
}
@@ -330,7 +360,9 @@ class GitHub implements IssueTracker {
private Optional<Milestone> findMilestone(ModuleIteration moduleIteration, String repositoryName) {
for (String state : Arrays.asList("close", "open")) {
AtomicReference<Milestone> milestoneRef = new AtomicReference<>();
for (String state : Arrays.asList("open", "closed")) {
Map<String, Object> parameters = newUrlTemplateVariables();
parameters.put("repoName", repositoryName);
@@ -340,23 +372,83 @@ class GitHub implements IssueTracker {
logger.log(moduleIteration, "Looking up milestone from %s…", milestoneUri);
List<GitHubIssue.Milestone> milestones = operations.exchange(MILESTONE_URI, HttpMethod.GET,
new HttpEntity<>(newUserScopedHttpHeaders()), MILESTONES_TYPE, parameters).getBody();
doWithPaging(MILESTONE_URI, HttpMethod.GET, parameters, new HttpEntity<>(newUserScopedHttpHeaders()),
MILESTONES_TYPE, milestones -> {
Optional<GitHubIssue.Milestone> milestone = milestones.stream(). //
filter(m -> m.matches(moduleIteration)). //
findFirst(). //
map(m -> {
logger.log(moduleIteration, "Found milestone %s.", m);
return m;
Optional<GitHubIssue.Milestone> milestone = milestones.stream(). //
filter(m -> m.matches(moduleIteration)). //
findFirst(). //
map(m -> {
logger.log(moduleIteration, "Found milestone %s.", m);
return m;
});
if (milestone.isPresent()) {
milestoneRef.set(milestone.get());
return false;
}
return true;
});
if (milestone.isPresent()) {
return milestone;
if (milestoneRef.get() != null) {
break;
}
}
return Optional.empty();
return Optional.ofNullable(milestoneRef.get());
}
/**
* Apply a {@link Predicate callback} with GitHub paging starting at {@code endpointUri}. The given
* {@link Predicate#test(Object)} outcome controls whether paging continues by returning {@literal true} or stops.
*
* @param endpointUri
* @param method
* @param parameters
* @param entity
* @param type
* @param callbackContinue
* @param <T>
*/
private <T> void doWithPaging(String endpointUri, HttpMethod method, Map<String, Object> parameters,
HttpEntity<?> entity, ParameterizedTypeReference<T> type, Predicate<T> callbackContinue) {
ResponseEntity<T> exchange = operations.exchange(endpointUri, method, entity, type, parameters);
Pattern pattern = Pattern.compile("<([^ ]*)>; rel=\"(\\w+)\"");
while (true) {
if (!callbackContinue.test(exchange.getBody())) {
return;
}
HttpHeaders responseHeaders = exchange.getHeaders();
List<String> links = responseHeaders.getValuesAsList("Link");
if (links.isEmpty()) {
return;
}
String nextLink = null;
for (String link : links) {
Matcher matcher = pattern.matcher(link);
if (matcher.find()) {
if (matcher.group(2).equals("next")) {
nextLink = matcher.group(1);
break;
}
}
}
if (nextLink == null) {
return;
}
exchange = operations.exchange(nextLink, method, entity, type, parameters);
}
}
/*
@@ -396,6 +488,7 @@ class GitHub implements IssueTracker {
// - if no next version exists, create
closeReleaseTicket(module);
}
private Tickets getTicketsFor(ModuleIteration moduleIteration, boolean forCurrentUser) {

View File

@@ -46,6 +46,10 @@ class GitHubIssue {
return new GitHubIssue(null, null, null, Collections.singletonList(username), null);
}
public GitHubIssue close() {
return new GitHubIssue(this.number, this.title, "closed", this.assignees, this.milestone);
}
public String getId() {
return number == null ? null : "#".concat(number);
}

View File

@@ -51,7 +51,6 @@ import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.util.UriTemplate;
@@ -74,7 +73,14 @@ class Jira implements JiraConnector {
private static final String INFRASTRUCTURE_COMPONENT_NAME = "Infrastructure";
private static final String IN_PROGRESS_STATUS_CATEGORY = "indeterminate";
/**
* Values/Id's originate from https://jira.spring.io/rest/api/2/issue/(Ticket)/transitions?expand=transitions.fields
*/
private static final int IN_PROGRESS_TRANSITION = 4;
private static final int CLOSE_TRANSITION = 2;
private static final int RESOLVE_TRANSITION = 5;
private static final String COMPLETE_RESOLUTION = "Complete";
private final RestOperations operations;
private final Logger logger;
@@ -409,6 +415,34 @@ class Jira implements JiraConnector {
}
}
private void resolve(Ticket ticket) {
Assert.notNull(ticket, "Ticket must not be null.");
HttpHeaders httpHeaders = newUserScopedHttpHeaders();
Map<String, Object> parameters = newUrlTemplateVariables();
parameters.put("ticketId", ticket.getId());
JiraIssue currentIssue = getJiraIssue(ticket.getId())
.orElseThrow(() -> new IllegalStateException(String.format("Ticket %s does not exist", ticket.getId())));
if (isInProgress(currentIssue.getFields())) {
JiraIssueUpdate editMeta = JiraIssueUpdate.create().transition(RESOLVE_TRANSITION)
.resolution(COMPLETE_RESOLUTION);
logger.log("Ticket", "Resolving %s", ticket);
try {
operations.exchange(TRANSITION_TEMPLATE, HttpMethod.POST, new HttpEntity<Object>(editMeta, httpHeaders),
String.class, parameters).getBody();
} catch (HttpClientErrorException e) {
logger.warn("Ticket", "Resolution of %s failed with status ", ticket, e.getStatusCode());
}
}
}
private static boolean isInProgress(Fields fields) {
if (fields.getStatus() == null) {
@@ -484,6 +518,8 @@ class Jira implements JiraConnector {
parameters);
});
resolve(getReleaseTicketFor(module));
// - if no next version exists, create
}
@@ -627,8 +663,14 @@ class Jira implements JiraConnector {
parameters.put("fields", "summary,status,resolution,fixVersions");
parameters.put("startAt", startAt);
return operations.exchange(PROJECT_VERSIONS_TEMPLATE, HttpMethod.GET, new HttpEntity<>(headers),
JiraReleaseVersions.class, parameters).getBody();
try {
return operations.exchange(PROJECT_VERSIONS_TEMPLATE, HttpMethod.GET, new HttpEntity<>(headers),
JiraReleaseVersions.class, parameters).getBody();
} catch (HttpStatusCodeException e) {
System.out.println(e.getResponseBodyAsString());
throw e;
}
}
private Ticket toTicket(JiraIssue issue) {

View File

@@ -43,6 +43,7 @@ class JiraIssueUpdate {
private final Map<String, Object> update;
private final Map<String, Object> transition;
private final Map<String, Map<String, Object>> fields;
/**
* Create an empty {@link JiraIssueUpdate}.
@@ -50,7 +51,7 @@ class JiraIssueUpdate {
* @return
*/
public static JiraIssueUpdate create() {
return new JiraIssueUpdate(Collections.emptyMap(), Collections.emptyMap());
return new JiraIssueUpdate(Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap());
}
/**
@@ -66,7 +67,24 @@ class JiraIssueUpdate {
Map<String, Object> update = new LinkedHashMap<>(this.update);
update.put("assignee", new AssignTo(userId));
return new JiraIssueUpdate(update, this.transition);
return new JiraIssueUpdate(update, this.transition, this.fields);
}
/**
* Assign a {@code resolution} the issue.
*
* @param resolution must not be {@literal null} or empty.
* @return
*/
public JiraIssueUpdate resolution(String resolution) {
Assert.hasText(resolution, "Resolution must not be null or empty!");
Map<String, Map<String, Object>> fields = new LinkedHashMap<>(this.fields);
Map<String, Object> resolutionField = fields.computeIfAbsent("resolution", k -> new LinkedHashMap<>());
resolutionField.put("name", resolution);
return new JiraIssueUpdate(this.update, this.transition, fields);
}
/**
@@ -80,7 +98,7 @@ class JiraIssueUpdate {
Map<String, Object> transition = new LinkedHashMap<>(this.transition);
transition.put("id", transitionId);
return new JiraIssueUpdate(this.update, transition);
return new JiraIssueUpdate(this.update, transition, this.fields);
}
/**

View File

@@ -147,8 +147,8 @@ public class GitHubIssueTrackerIntegrationTests extends AbstractIntegrationTests
github.createReleaseVersion(BUILD_HOPPER_RC1);
verify(postRequestedFor(urlPathMatching(MILESTONES_URI))
.withRequestBody(equalToJson("{\"title\":\"1.8 RC1 (Hopper)\", \"description\":\"Hopper RC1\"}")));
verify(postRequestedFor(urlPathMatching(MILESTONES_URI)).withRequestBody(
equalToJson("{\"title\":\"1.8 RC1 (Hopper)\", \"description\":\"Hopper RC1\",\"open\":false}")));
}
/**
@@ -231,6 +231,27 @@ public class GitHubIssueTrackerIntegrationTests extends AbstractIntegrationTests
.withRequestBody(equalToJson("{\"assignees\":[\"mp911de\"]}")));
}
/**
* @see #94
*/
@Test
public void closeIterationShouldResolveReleaseTicket() {
mockGetMilestonesWith("milestones.json");
mockGetIssuesWith("issues.json");
mockService.stubFor(patch(urlPathMatching(RELEASE_TICKET_URI)).//
willReturn(json("issue.json")));
mockService.stubFor(patch(urlPathMatching(MILESTONES_URI + "/45")).//
willReturn(aResponse().withStatus(200)));
github.closeIteration(BUILD_HOPPER_RC1);
verify(patchRequestedFor(urlPathMatching(RELEASE_TICKET_URI))
.withRequestBody(equalToJson("{\"state\":\"closed\",\"assignees\":[\"mp911de\"]}")));
}
private void mockGetIssueWith(String fromClassPath, int issueId) {
mockService.stubFor(get(urlPathMatching(ISSUES_URI + "/" + issueId)).//
willReturn(json(fromClassPath)));

View File

@@ -298,8 +298,8 @@ public class JiraConnectorIntegrationTests extends AbstractIntegrationTests {
jira.assignTicketToMe(new Ticket("DATAREDIS-302", "", null));
verify(putRequestedFor(urlPathMatching("/rest/api/2/issue/DATAREDIS-302"))
.withRequestBody(equalToJson("{\"update\":{\"assignee\":[ {\"set\":{\"name\":\"dummy\"}} ] }}")));
verify(putRequestedFor(urlPathMatching("/rest/api/2/issue/DATAREDIS-302")).withRequestBody(equalToJson(
"{\"update\":{\"assignee\":[ {\"set\":{\"name\":\"dummy\"}} ] }, \"transition\":{}, \"fields\":{}}")));
}
/**
@@ -321,6 +321,28 @@ public class JiraConnectorIntegrationTests extends AbstractIntegrationTests {
verify(0, postRequestedFor(urlPathMatching("/rest/api/2/issue/DATACASS-302")));
}
/**
* @see #94
*/
@Test
public void closeIterationShouldResolveReleaseTicket() {
ModuleIteration moduleIteration = ReleaseTrains.HOPPER.getModuleIteration(Iteration.RC1, "REST");
properties.setUsername("mp911de");
mockGetProjectVersionsWith("releaseVersions.json", moduleIteration.getProjectKey());
mockSearchWith("releaseTickets.json");
mockService.stubFor(get(urlPathMatching("/rest/api/2/issue/DATAREST-782")).//
willReturn(json("releaseTicket.json")));
jira.closeIteration(moduleIteration);
verify(postRequestedFor(urlPathMatching("/rest/api/2/issue/DATAREST-782")).withRequestBody(
equalToJson("{\"update\":{},\"transition\":{\"id\":5},\"fields\":{\"resolution\":{\"name\":\"Complete\"}}}")));
}
private void mockSearchWith(String fromClassPath) {
mockService.stubFor(get(urlPathMatching(SEARCH_URI)).//
willReturn(json(fromClassPath)));

View File

@@ -0,0 +1,22 @@
{
"expand": "operations,editmeta,changelog,transitions,renderedFields",
"id": "67908",
"self": "https://jira.spring.io/rest/api/2/issue/67908",
"key": "DATAREST-782",
"fields": {
"summary": "Release 2.5 RC1 (Hopper)",
"resolution": {
"self": "https://jira.spring.io/rest/api/2/resolution/1",
"id": "1",
"description": "A fix for this issue is checked into the tree and tested.",
"name": "Fixed"
},
"status": {
"name": "In Progress",
"id": "6",
"statusCategory": {
"key": "indeterminate"
}
}
}
}