From e51c06c8e33ca0118d421d17cfad593426e9dc2f Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 11 Dec 2018 09:48:18 +0100 Subject: [PATCH] =?UTF-8?q?#94=20-=20Extend=20IssueTracker.closeIteration(?= =?UTF-8?q?=E2=80=A6)=20to=20resolve=20release=20tickets.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeIteration() now resolves release tickets within its release version to simplify post-release tasks. --- .../data/release/issues/github/GitHub.java | 117 ++++++++++++++++-- .../release/issues/github/GitHubIssue.java | 4 + .../data/release/issues/jira/Jira.java | 48 ++++++- .../release/issues/jira/JiraIssueUpdate.java | 24 +++- .../GitHubIssueTrackerIntegrationTests.java | 25 +++- .../jira/JiraConnectorIntegrationTests.java | 26 +++- .../jira/__files/releaseTicket.json | 22 ++++ 7 files changed, 244 insertions(+), 22 deletions(-) create mode 100644 release-tools/src/test/resources/integration/jira/__files/releaseTicket.json diff --git a/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHub.java b/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHub.java index b7e1560..5bef78a 100644 --- a/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHub.java +++ b/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHub.java @@ -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 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 findMilestone(ModuleIteration moduleIteration, String repositoryName) { - for (String state : Arrays.asList("close", "open")) { + AtomicReference milestoneRef = new AtomicReference<>(); + + for (String state : Arrays.asList("open", "closed")) { Map parameters = newUrlTemplateVariables(); parameters.put("repoName", repositoryName); @@ -340,23 +372,83 @@ class GitHub implements IssueTracker { logger.log(moduleIteration, "Looking up milestone from %s…", milestoneUri); - List 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 milestone = milestones.stream(). // - filter(m -> m.matches(moduleIteration)). // - findFirst(). // - map(m -> { - logger.log(moduleIteration, "Found milestone %s.", m); - return m; + Optional 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 + */ + private void doWithPaging(String endpointUri, HttpMethod method, Map parameters, + HttpEntity entity, ParameterizedTypeReference type, Predicate callbackContinue) { + + ResponseEntity 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 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) { diff --git a/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHubIssue.java b/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHubIssue.java index 6139e9b..e8780f7 100644 --- a/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHubIssue.java +++ b/release-tools/src/main/java/org/springframework/data/release/issues/github/GitHubIssue.java @@ -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); } diff --git a/release-tools/src/main/java/org/springframework/data/release/issues/jira/Jira.java b/release-tools/src/main/java/org/springframework/data/release/issues/jira/Jira.java index c629b4c..27c1302 100644 --- a/release-tools/src/main/java/org/springframework/data/release/issues/jira/Jira.java +++ b/release-tools/src/main/java/org/springframework/data/release/issues/jira/Jira.java @@ -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 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(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) { diff --git a/release-tools/src/main/java/org/springframework/data/release/issues/jira/JiraIssueUpdate.java b/release-tools/src/main/java/org/springframework/data/release/issues/jira/JiraIssueUpdate.java index 1d6dcbf..f64e3a3 100644 --- a/release-tools/src/main/java/org/springframework/data/release/issues/jira/JiraIssueUpdate.java +++ b/release-tools/src/main/java/org/springframework/data/release/issues/jira/JiraIssueUpdate.java @@ -43,6 +43,7 @@ class JiraIssueUpdate { private final Map update; private final Map transition; + private final Map> 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 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> fields = new LinkedHashMap<>(this.fields); + Map 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 transition = new LinkedHashMap<>(this.transition); transition.put("id", transitionId); - return new JiraIssueUpdate(this.update, transition); + return new JiraIssueUpdate(this.update, transition, this.fields); } /** diff --git a/release-tools/src/test/java/org/springframework/data/release/issues/github/GitHubIssueTrackerIntegrationTests.java b/release-tools/src/test/java/org/springframework/data/release/issues/github/GitHubIssueTrackerIntegrationTests.java index 6f1bee5..3927296 100644 --- a/release-tools/src/test/java/org/springframework/data/release/issues/github/GitHubIssueTrackerIntegrationTests.java +++ b/release-tools/src/test/java/org/springframework/data/release/issues/github/GitHubIssueTrackerIntegrationTests.java @@ -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))); diff --git a/release-tools/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java b/release-tools/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java index 179b86c..469d949 100644 --- a/release-tools/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java +++ b/release-tools/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java @@ -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))); diff --git a/release-tools/src/test/resources/integration/jira/__files/releaseTicket.json b/release-tools/src/test/resources/integration/jira/__files/releaseTicket.json new file mode 100644 index 0000000..9711ce9 --- /dev/null +++ b/release-tools/src/test/resources/integration/jira/__files/releaseTicket.json @@ -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" + } + } + } +}