#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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user