diff --git a/pom.xml b/pom.xml index 2bc06d6..e4163b8 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,5 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.springframework.data.build spring-data-release-cli @@ -115,6 +115,12 @@ test + + javax.servlet + javax.servlet-api + test + + com.google.guava guava diff --git a/src/main/java/org/springframework/data/release/issues/jira/Jira.java b/src/main/java/org/springframework/data/release/issues/jira/Jira.java index 4da29f5..f73b7ad 100644 --- a/src/main/java/org/springframework/data/release/issues/jira/Jira.java +++ b/src/main/java/org/springframework/data/release/issues/jira/Jira.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2016 the original author or authors. + * Copyright 2013-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,8 +44,11 @@ import org.springframework.data.release.utils.Logger; 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.HttpClientErrorException; import org.springframework.web.client.RestOperations; import org.springframework.web.util.UriTemplate; @@ -58,7 +61,7 @@ class Jira implements JiraConnector { private static final String BASE_URI = "{jiraBaseUrl}/rest/api/2"; private static final String CREATE_ISSUES_TEMPLATE = BASE_URI + "/issue"; - private static final String UPDATE_ISSUE_TEMPLATE = BASE_URI + "/issue/{ticketId}"; + private static final String ISSUE_TEMPLATE = BASE_URI + "/issue/{ticketId}"; private static final String PROJECT_VERSIONS_TEMPLATE = BASE_URI + "/project/{project}/version?startAt={startAt}"; private static final String PROJECT_COMPONENTS_TEMPLATE = BASE_URI + "/project/{project}/components"; private static final String VERSIONS_TEMPLATE = BASE_URI + "/version"; @@ -298,13 +301,27 @@ class Jira implements JiraConnector { 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 (currentIssue.isAssignedTo(jiraProperties.getUsername())) { + logger.log("Ticket", "Skipping self-assignment of %s", ticket); + return; + } + JiraIssue jiraIssue = JiraIssue.create().assignTo(jiraProperties.getCredentials()); - operations.exchange(UPDATE_ISSUE_TEMPLATE, HttpMethod.POST, new HttpEntity(jiraIssue, httpHeaders), - String.class, parameters).getBody(); + logger.log("Ticket", "Self-assignment of %s", ticket); + + try { + operations.exchange(ISSUE_TEMPLATE, HttpMethod.POST, new HttpEntity(jiraIssue, httpHeaders), String.class, + parameters).getBody(); + } catch (HttpClientErrorException e) { + logger.warn("Ticket", "Self-assignment of %s failed with status ", ticket, e.getStatusCode()); + } } - /* + /* * (non-Javadoc) * @see org.springframework.data.release.jira.IssueTracker#assignReleaseTicketToMe(org.springframework.data.release.model.ModuleIteration) */ @@ -444,6 +461,24 @@ class Jira implements JiraConnector { return jiraIssue; } + private Optional getJiraIssue(String ticketId) { + + Map parameters = newUrlTemplateVariables(); + parameters.put("ticketId", ticketId); + + try { + ResponseEntity jiraIssue = operations.getForEntity(ISSUE_TEMPLATE, JiraIssue.class, parameters); + + return Optional.of(jiraIssue.getBody()); + } catch (HttpClientErrorException e) { + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } + + throw e; + } + } + private JiraIssues execute(String context, JqlQuery query, HttpHeaders headers, JiraIssuesCallback callback) { JiraIssues issues; @@ -530,7 +565,7 @@ class Jira implements JiraConnector { /** * Returns new {@link HttpHeaders} with authentication headers. - * + * * @return */ private HttpHeaders newUserScopedHttpHeaders() { diff --git a/src/main/java/org/springframework/data/release/issues/jira/JiraIssue.java b/src/main/java/org/springframework/data/release/issues/jira/JiraIssue.java index b92a59a..591e8c9 100644 --- a/src/main/java/org/springframework/data/release/issues/jira/JiraIssue.java +++ b/src/main/java/org/springframework/data/release/issues/jira/JiraIssue.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2016 the original author or authors. + * Copyright 2014-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,7 +42,7 @@ class JiraIssue { /** * Creates a new Jira issue value object. - * + * * @return */ public static JiraIssue create() { @@ -51,7 +51,7 @@ class JiraIssue { /** * Creates a new Jira issue value object with a issue type of {@link IssueType#TASK}. - * + * * @return */ public static JiraIssue createTask() { @@ -98,7 +98,7 @@ class JiraIssue { /** * Assign the ticket to {@code username}. - * + * * @param username must not be empty and not {@literal null}. * @return */ @@ -111,7 +111,7 @@ class JiraIssue { /** * Set the project to {@code projectKey}. - * + * * @param projectKey must not be {@literal null}. * @return */ @@ -123,7 +123,7 @@ class JiraIssue { /** * Set the issue summary. - * + * * @param summary must not be empty and not {@literal null}. */ public JiraIssue summary(String summary) { @@ -135,7 +135,7 @@ class JiraIssue { /** * Set the fix version. - * + * * @param moduleIteration must not be {@literal null}. * @return */ @@ -147,6 +147,10 @@ class JiraIssue { return this; } + public boolean isAssignedTo(String username) { + return getFields().getAssignee() != null && getFields().getAssignee().matches(username); + } + private static boolean wasBackportedFrom(Train train, List fixVersions) { return fixVersions.size() > 1 && isPresent(train, fixVersions); } @@ -252,5 +256,9 @@ class JiraIssue { static JiraUser from(String username) { return new JiraUser(username, null); } + + public boolean matches(String username) { + return username.equalsIgnoreCase(name); + } } } diff --git a/src/main/java/org/springframework/data/release/utils/Logger.java b/src/main/java/org/springframework/data/release/utils/Logger.java index fa7e48d..20b6cb4 100644 --- a/src/main/java/org/springframework/data/release/utils/Logger.java +++ b/src/main/java/org/springframework/data/release/utils/Logger.java @@ -1,5 +1,5 @@ /* - * Copyright 2014 the original author or authors. + * Copyright 2014-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import org.springframework.stereotype.Component; /** * @author Oliver Gierke + * @author Mark Paluch */ @Component public class Logger { @@ -51,4 +52,8 @@ public class Logger { public void log(String context, Object template, Object... args) { LOGGER.info(String.format(PREFIX_TEMPLATE, context, String.format(template.toString(), args))); } + + public void warn(String context, Object template, Object... args) { + LOGGER.warning(String.format(PREFIX_TEMPLATE, context, String.format(template.toString(), args))); + } } diff --git a/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java b/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java index 6a4090a..d5f904a 100644 --- a/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java +++ b/src/test/java/org/springframework/data/release/issues/jira/JiraConnectorIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016 the original author or authors. + * Copyright 2016-2017 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.data.release.issues.jira; import static com.github.tomakehurst.wiremock.client.WireMock.*; @@ -34,10 +33,6 @@ import org.junit.rules.ExpectedException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.release.AbstractIntegrationTests; import org.springframework.data.release.issues.Ticket; -import org.springframework.data.release.issues.jira.Jira; -import org.springframework.data.release.issues.jira.JiraConnector; -import org.springframework.data.release.issues.jira.JiraProperties; -import org.springframework.data.release.issues.jira.JiraReleaseVersion; import org.springframework.data.release.model.Iteration; import org.springframework.data.release.model.ModuleIteration; import org.springframework.data.release.model.ProjectKey; @@ -54,7 +49,7 @@ import com.github.tomakehurst.wiremock.junit.WireMockRule; /** * Integration Tests for {@link Jira} using a local {@link WireMockRule} server. - * + * * @author Mark Paluch */ public class JiraConnectorIntegrationTests extends AbstractIntegrationTests { @@ -80,6 +75,7 @@ public class JiraConnectorIntegrationTests extends AbstractIntegrationTests { UriComponents uriComponents = UriComponentsBuilder.fromUriString(properties.getApiUrl()).build(); Assume.assumeThat(uriComponents.getHost(), is("localhost")); + properties.setUsername("dummy"); jira.reset(); } @@ -209,7 +205,6 @@ public class JiraConnectorIntegrationTests extends AbstractIntegrationTests { + "\"issuetype\":{\"name\":\"Task\"},\"summary\":\"Release 2.5 RC1 (Hopper)\"," + "\"fixVersions\":[{\"name\":\"2.5 RC1 (Hopper)\"}]," + "\"components\":[{\"name\":\"Infrastructure\"}]}}"))); - } /** @@ -231,7 +226,6 @@ public class JiraConnectorIntegrationTests extends AbstractIntegrationTests { .withRequestBody(equalToJson("{\"fields\":{\"project\":{\"key\":\"DATAREST\"}," + "\"issuetype\":{\"name\":\"Task\"},\"summary\":\"Release 2.5 RC1 (Hopper)\"," + "\"fixVersions\":[{\"name\":\"2.5 RC1 (Hopper)\"}]}}"))); - } /** @@ -274,17 +268,39 @@ public class JiraConnectorIntegrationTests extends AbstractIntegrationTests { * @see #5 */ @Test - public void assignTicketToMe() throws Exception { + public void assignTicketToMe() { - mockService.stubFor(post(urlPathMatching("/rest/api/2/issue/DATAREDIS-99999")).// + mockService.stubFor(get(urlPathMatching("/rest/api/2/issue/DATAREDIS-302")).// + willReturn(json("existingTicket.json"))); + + mockService.stubFor(post(urlPathMatching("/rest/api/2/issue/DATAREDIS-302")).// willReturn(aResponse().withStatus(204))); jira.assignTicketToMe(new Ticket("DATAREDIS-99999", "", null)); - verify(postRequestedFor(urlPathMatching("/rest/api/2/issue/DATAREDIS-99999")) + verify(postRequestedFor(urlPathMatching("/rest/api/2/issue/DATAREDIS-302")) .withRequestBody(equalToJson("{\"fields\":{\"assignee\":{\"name\":\"dummy\"}}}"))); } + /** + * @see #5, #53 + */ + @Test + public void skipTicketAssignmentIfAssigned() { + + properties.setUsername("mp911de"); + + mockService.stubFor(get(urlPathMatching("/rest/api/2/issue/DATACASS-302")).// + willReturn(json("existingTicket.json"))); + + mockService.stubFor(post(urlPathMatching("/rest/api/2/issue/DATACASS-302")).// + willReturn(aResponse().withStatus(204))); + + jira.assignTicketToMe(new Ticket("DATACASS-302", "", null)); + + verify(0, postRequestedFor(urlPathMatching("/rest/api/2/issue/DATACASS-302"))); + } + private void mockSearchWith(String fromClassPath) { mockService.stubFor(get(urlPathMatching(SEARCH_URI)).// willReturn(json(fromClassPath))); diff --git a/src/test/resources/integration/jira/__files/existingTicket.json b/src/test/resources/integration/jira/__files/existingTicket.json new file mode 100644 index 0000000..81c4fe7 --- /dev/null +++ b/src/test/resources/integration/jira/__files/existingTicket.json @@ -0,0 +1,173 @@ +{ + "expand": "renderedFields,names,schema,transitions,operations,editmeta,changelog", + "id": "68967", + "self": "https://jira.spring.io/rest/api/2/issue/68967", + "key": "DATACASS-302", + "fields": { + "issuetype": { + "self": "https://jira.spring.io/rest/api/2/issuetype/3", + "id": "3", + "description": "A task that needs to be done.", + "iconUrl": "https://jira.spring.io/images/icons/issuetypes/task.png", + "name": "Task", + "subtask": false + }, + "timespent": null, + "customfield_10150": null, + "project": { + "self": "https://jira.spring.io/rest/api/2/project/11403", + "id": "11403", + "key": "DATACASS", + "name": "Spring Data for Apache Cassandra", + "avatarUrls": { + "48x48": "https://jira.spring.io/secure/projectavatar?pid=11403&avatarId=12504", + "24x24": "https://jira.spring.io/secure/projectavatar?size=small&pid=11403&avatarId=12504", + "16x16": "https://jira.spring.io/secure/projectavatar?size=xsmall&pid=11403&avatarId=12504", + "32x32": "https://jira.spring.io/secure/projectavatar?size=medium&pid=11403&avatarId=12504" + }, + "projectCategory": { + "self": "https://jira.spring.io/rest/api/2/projectCategory/10000", + "id": "10000", + "description": "Top-level Spring projects", + "name": "Spring Projects" + } + }, + "fixVersions": [], + "aggregatetimespent": null, + "resolution": null, + "resolutiondate": null, + "workratio": -1, + "lastViewed": null, + "watches": { + "self": "https://jira.spring.io/rest/api/2/issue/DATACASS-302/watchers", + "watchCount": 1, + "isWatching": true + }, + "customfield_10180": "27907200", + "customfield_10181": "true", + "customfield_10380": "9223372036854775807", + "customfield_10182": "mp911de(mp911de)", + "created": "2016-06-20T11:54:21.542+0000", + "customfield_10381": "9223372036854775807", + "customfield_10140": null, + "customfield_10580": null, + "customfield_10142": null, + "priority": { + "self": "https://jira.spring.io/rest/api/2/priority/4", + "iconUrl": "https://jira.spring.io/images/icons/priorities/minor.png", + "name": "Minor", + "id": "4" + }, + "customfield_10981": null, + "labels": [], + "timeestimate": null, + "aggregatetimeoriginalestimate": null, + "versions": [], + "issuelinks": [], + "assignee": { + "self": "https://jira.spring.io/rest/api/2/user?username=mp911de", + "name": "mp911de", + "key": "mp911de", + "emailAddress": "mpaluch at pivotal dot io", + "avatarUrls": { + "48x48": "https://jira.spring.io/secure/useravatar?ownerId=mp911de&avatarId=14015", + "24x24": "https://jira.spring.io/secure/useravatar?size=small&ownerId=mp911de&avatarId=14015", + "16x16": "https://jira.spring.io/secure/useravatar?size=xsmall&ownerId=mp911de&avatarId=14015", + "32x32": "https://jira.spring.io/secure/useravatar?size=medium&ownerId=mp911de&avatarId=14015" + }, + "displayName": "Mark Paluch", + "active": true, + "timeZone": "Europe/Berlin" + }, + "updated": "2016-06-20T11:54:42.720+0000", + "status": { + "self": "https://jira.spring.io/rest/api/2/status/1", + "description": "The issue is open and ready for the assignee to start work on it.", + "iconUrl": "https://jira.spring.io/images/icons/statuses/open.png", + "name": "Open", + "id": "1", + "statusCategory": { + "self": "https://jira.spring.io/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "To Do" + } + }, + "components": [], + "customfield_10170": null, + "customfield_10171": null, + "timeoriginalestimate": null, + "description": "Add support for Cassandra {{time}} data type.\r\n\r\n{{time}} represents a Time string, such as 13:30:54.234 but it's mapped to the {{long}} Java type. The {{long}} value denotes millis since midnight. Updates require quoting of the value:\r\n\r\n{code}\r\nUPDATE mytable SET mytimefield ='123456';\r\n{code}\r\n\r\nPlease see also https://issues.apache.org/jira/browse/CASSANDRA-11798", + "timetracking": {}, + "attachment": [], + "aggregatetimeestimate": null, + "summary": "Support Cassandra time columns", + "creator": { + "self": "https://jira.spring.io/rest/api/2/user?username=mp911de", + "name": "mp911de", + "key": "mp911de", + "emailAddress": "mpaluch at pivotal dot io", + "avatarUrls": { + "48x48": "https://jira.spring.io/secure/useravatar?ownerId=mp911de&avatarId=14015", + "24x24": "https://jira.spring.io/secure/useravatar?size=small&ownerId=mp911de&avatarId=14015", + "16x16": "https://jira.spring.io/secure/useravatar?size=xsmall&ownerId=mp911de&avatarId=14015", + "32x32": "https://jira.spring.io/secure/useravatar?size=medium&ownerId=mp911de&avatarId=14015" + }, + "displayName": "Mark Paluch", + "active": true, + "timeZone": "Europe/Berlin" + }, + "customfield_10280": "9223372036854775807", + "subtasks": [], + "customfield_10480": null, + "customfield_10680": null, + "customfield_10120": null, + "reporter": { + "self": "https://jira.spring.io/rest/api/2/user?username=mp911de", + "name": "mp911de", + "key": "mp911de", + "emailAddress": "mpaluch at pivotal dot io", + "avatarUrls": { + "48x48": "https://jira.spring.io/secure/useravatar?ownerId=mp911de&avatarId=14015", + "24x24": "https://jira.spring.io/secure/useravatar?size=small&ownerId=mp911de&avatarId=14015", + "16x16": "https://jira.spring.io/secure/useravatar?size=xsmall&ownerId=mp911de&avatarId=14015", + "32x32": "https://jira.spring.io/secure/useravatar?size=medium&ownerId=mp911de&avatarId=14015" + }, + "displayName": "Mark Paluch", + "active": true, + "timeZone": "Europe/Berlin" + }, + "customfield_10000": null, + "customfield_10880": "0|i09a87:", + "aggregateprogress": { + "progress": 0, + "total": 0 + }, + "customfield_10002": null, + "customfield_10684": null, + "environment": null, + "duedate": null, + "progress": { + "progress": 0, + "total": 0 + }, + "comment": { + "startAt": 0, + "maxResults": 0, + "total": 0, + "comments": [] + }, + "votes": { + "self": "https://jira.spring.io/rest/api/2/issue/DATACASS-302/votes", + "votes": 0, + "hasVoted": false + }, + "worklog": { + "startAt": 0, + "maxResults": 20, + "total": 0, + "worklogs": [] + } + } +}