Skip to content

Commit

Permalink
feat(jira): Add Jira issue transition to JiraNotificationService (#1080)
Browse files Browse the repository at this point in the history
* feat(jira): Add Jira issue transition to JiraNotificationService

`transitionContext` payload can be the full Jira transition REST API payload, however the transition ID will be looked up based on the transition name

* feat(jira): Fixup and add ability to optionally send comment when transitioning issue
  • Loading branch information
jonsie committed Feb 4, 2021
1 parent 1e498d9 commit 8898b68
Show file tree
Hide file tree
Showing 4 changed files with 283 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import static retrofit.Endpoints.newFixedEndpoint;

import com.netflix.spinnaker.echo.jackson.EchoObjectMapper;
import com.netflix.spinnaker.echo.jira.JiraProperties;
import com.netflix.spinnaker.echo.jira.JiraService;
import com.netflix.spinnaker.retrofit.Slf4jRetrofitLogger;
Expand Down Expand Up @@ -54,7 +55,7 @@ JiraService jiraService(
RestAdapter.Builder builder =
new RestAdapter.Builder()
.setEndpoint(newFixedEndpoint(jiraProperties.getBaseUrl()))
.setConverter(new JacksonConverter())
.setConverter(new JacksonConverter(EchoObjectMapper.getInstance()))
.setClient(retrofitClient)
.setLogLevel(retrofitLogLevel)
.setLog(new Slf4jRetrofitLogger(JiraService.class));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,23 @@

import static net.logstash.logback.argument.StructuredArguments.kv;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableMap;
import com.netflix.spinnaker.echo.api.Notification;
import com.netflix.spinnaker.echo.controller.EchoResponse;
import com.netflix.spinnaker.echo.jira.JiraService.CreateJiraIssueRequest;
import com.netflix.spinnaker.echo.jira.JiraService.CreateJiraIssueResponse;
import com.netflix.spinnaker.echo.jira.JiraService.CommentIssueRequest;
import com.netflix.spinnaker.echo.jira.JiraService.CreateIssueRequest;
import com.netflix.spinnaker.echo.jira.JiraService.CreateIssueResponse;
import com.netflix.spinnaker.echo.jira.JiraService.IssueTransitions;
import com.netflix.spinnaker.echo.jira.JiraService.TransitionIssueRequest;
import com.netflix.spinnaker.echo.notification.NotificationService;
import com.netflix.spinnaker.kork.core.RetrySupport;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -37,36 +43,102 @@
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ResponseStatus;
import retrofit.RetrofitError;
import retrofit.client.Response;

@Component
@ConditionalOnProperty("jira.enabled")
public class JiraNotificationService implements NotificationService {
private static final Logger LOGGER = LoggerFactory.getLogger(JiraNotificationService.class);
private static final int MAX_RETRY = 3;
private static final long RETRY_BACKOFF = 3;
private static final long RETRY_BACKOFF = 100;

private final JiraService jiraService;
private final RetrySupport retrySupport;
private final ObjectMapper mapper;

@Autowired
public JiraNotificationService(JiraService jiraService, ObjectMapper objectMapper) {
public JiraNotificationService(
JiraService jiraService, RetrySupport retrySupport, ObjectMapper objectMapper) {
this.jiraService = jiraService;
this.retrySupport = new RetrySupport();
this.retrySupport = retrySupport;
this.mapper = objectMapper;
}

@Override
public boolean supportsType(String type) {
return "JIRA".equals(type.toUpperCase());
return "JIRA".equalsIgnoreCase(type);
}

@Override
public EchoResponse<CreateJiraIssueResponse> handle(Notification notification) {
public EchoResponse handle(Notification notification) {
return isTransition(notification) ? transitionIssue(notification) : create(notification);
}

private boolean isTransition(Notification notification) {
return notification.getAdditionalContext().get("transitionContext") != null;
}

private EchoResponse.Void transitionIssue(Notification notification) {
TransitionJiraNotification transitionNotification =
mapper.convertValue(notification.getAdditionalContext(), TransitionJiraNotification.class);
String jiraIssue = transitionNotification.getJiraIssue();

try {
// transitionContext is the full Jira transition API payload (which is stored in
// transitionDetails) - except the transition ID is probably unknown. So, we get the
// transition ID from the transition name.
Map<String, String> transition =
transitionNotification.getTransitionContext().getTransition();
Map<String, Object> transitionDetails =
transitionNotification.getTransitionContext().getTransitionDetails();
String transitionName = transition.get("name");

IssueTransitions issueTransitions =
retrySupport.retry(getIssueTransitions(jiraIssue), MAX_RETRY, RETRY_BACKOFF, false);

issueTransitions.getTransitions().stream()
.filter(it -> it.getName().equals(transitionName))
.findFirst()
.ifPresentOrElse(
t -> {
transition.put("id", t.getId());
transitionDetails.put("transition", transition);
},
() -> {
throw new IllegalArgumentException(
ImmutableMap.of(
"issue", jiraIssue,
"transitionName", transitionName,
"validTransitionNames",
issueTransitions.getTransitions().stream()
.map(IssueTransitions.Transition::getName)
.collect(Collectors.toList()))
.toString());
});

retrySupport.retry(
transitionIssue(jiraIssue, transitionDetails), MAX_RETRY, RETRY_BACKOFF, false);

if (transitionNotification.getComment() != null) {
retrySupport.retry(
addComment(jiraIssue, transitionNotification.getComment()),
MAX_RETRY,
RETRY_BACKOFF,
false);
}

return new EchoResponse.Void();
} catch (Exception e) {
throw new TransitionJiraIssueException(
String.format("Failed to transition Jira issue %s: %s", jiraIssue, errors(e)), e);
}
}

private EchoResponse<CreateIssueResponse> create(Notification notification) {
Map<String, Object> issueRequestBody = issueRequestBody(notification);
try {
CreateJiraIssueResponse response =
retrySupport.retry(createJiraIssue(issueRequestBody), MAX_RETRY, RETRY_BACKOFF, false);
CreateIssueResponse response =
retrySupport.retry(createIssue(issueRequestBody), MAX_RETRY, RETRY_BACKOFF, false);

return new EchoResponse<>(response);
} catch (Exception e) {
Expand All @@ -78,8 +150,22 @@ public EchoResponse<CreateJiraIssueResponse> handle(Notification notification) {
}
}

private Supplier<CreateJiraIssueResponse> createJiraIssue(Map<String, Object> issueRequestBody) {
return () -> jiraService.createJiraIssue(new CreateJiraIssueRequest(issueRequestBody));
private Supplier<IssueTransitions> getIssueTransitions(String issueIdOrKey) {
return () -> jiraService.getIssueTransitions(issueIdOrKey);
}

private Supplier<Response> transitionIssue(
String issueIdOrKey, Map<String, Object> transitionDetails) {
return () ->
jiraService.transitionIssue(issueIdOrKey, new TransitionIssueRequest(transitionDetails));
}

private Supplier<Response> addComment(String issueIdOrKey, String comment) {
return () -> jiraService.addComment(issueIdOrKey, new CommentIssueRequest(comment));
}

private Supplier<CreateIssueResponse> createIssue(Map<String, Object> issueRequestBody) {
return () -> jiraService.createIssue(new CreateIssueRequest(issueRequestBody));
}

private Map<String, Object> issueRequestBody(Notification notification) {
Expand Down Expand Up @@ -113,4 +199,65 @@ public CreateJiraIssueException(String message, Throwable cause) {
super(message, cause);
}
}

@ResponseStatus(value = HttpStatus.BAD_REQUEST)
static class TransitionJiraIssueException extends RuntimeException {
public TransitionJiraIssueException(String message, Throwable cause) {
super(message, cause);
}
}

static class TransitionJiraNotification {
private String jiraIssue;
private String comment;
private TransitionContext transitionContext;

public String getJiraIssue() {
return jiraIssue;
}

public void setJiraIssue(String jiraIssue) {
this.jiraIssue = jiraIssue;
}

public String getComment() {
return comment;
}

public void setComment(String comment) {
this.comment = comment;
}

public TransitionContext getTransitionContext() {
return transitionContext;
}

public void setTransitionContext(TransitionContext transitionContext) {
this.transitionContext = transitionContext;
}

static class TransitionContext {
private Map<String, String> transition;

// placeholder for all the other remaining transition context payload
private Map<String, Object> transitionDetails = new HashMap<>();

public Map<String, String> getTransition() {
return transition;
}

public void setTransition(Map<String, String> transition) {
this.transition = transition;
}

public Map<String, Object> getTransitionDetails() {
return transitionDetails;
}

@JsonAnySetter
public void setTransitionDetails(String name, Object value) {
this.transitionDetails.put(name, value);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,92 @@

import com.netflix.spinnaker.echo.controller.EchoResponse;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import retrofit.client.Response;
import retrofit.http.Body;
import retrofit.http.GET;
import retrofit.http.POST;
import retrofit.http.Path;

public interface JiraService {
@POST("/rest/api/2/issue/")
CreateJiraIssueResponse createJiraIssue(@Body CreateJiraIssueRequest createJiraIssueRequest);
CreateIssueResponse createIssue(@Body CreateIssueRequest createIssueRequest);

class CreateJiraIssueRequest extends HashMap<String, Object> {
CreateJiraIssueRequest(Map<String, Object> body) {
@GET("/rest/api/2/issue/{issueIdOrKey}/transitions")
IssueTransitions getIssueTransitions(@Path("issueIdOrKey") String issueIdOrKey);

@POST("/rest/api/2/issue/{issueIdOrKey}/transitions")
Response transitionIssue(
@Path("issueIdOrKey") String issueIdOrKey,
@Body TransitionIssueRequest transitionIssueRequest);

@POST("/rest/api/2/issue/{issueIdOrKey}/comment")
Response addComment(
@Path("issueIdOrKey") String issueIdOrKey, @Body CommentIssueRequest commentIssueRequest);

class CreateIssueRequest extends HashMap<String, Object> {
CreateIssueRequest(Map<String, Object> body) {
super(body);
}
}

class TransitionIssueRequest extends HashMap<String, Object> {
TransitionIssueRequest(Map<String, Object> body) {
super(body);
}
}

class CreateJiraIssueResponse implements EchoResponse.EchoResult {
class CommentIssueRequest {
private String body;

CommentIssueRequest(String body) {
this.body = body;
}

public String getBody() {
return body;
}

public void setBody(String body) {
this.body = body;
}
}

class IssueTransitions {
private List<Transition> transitions;

public List<Transition> getTransitions() {
return transitions;
}

public void setTransitions(List<Transition> transitions) {
this.transitions = transitions;
}

public static class Transition {
private String id;
private String name;

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}
}

class CreateIssueResponse implements EchoResponse.EchoResult {
private String id;
private String key;
private String self;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.netflix.spinnaker.echo.jira

import com.fasterxml.jackson.databind.ObjectMapper
import com.netflix.spinnaker.echo.api.Notification
import com.netflix.spinnaker.echo.jackson.EchoObjectMapper
import com.netflix.spinnaker.kork.core.RetrySupport
import spock.lang.Specification
import spock.lang.Unroll


class JiraNotificationServiceSpec extends Specification {

def jiraService = Mock(JiraService)
def retrySupport = new RetrySupport()
def objectMapper = EchoObjectMapper.getInstance()
JiraNotificationService service = new JiraNotificationService(jiraService, retrySupport, objectMapper)

@Unroll
void 'Handles Jira transition, calls comment if comment is set'() {
given:
def notification = new Notification(
notificationType: "JIRA",
source: new Notification.Source(user: "foo@example.com"),
additionalContext: [
jiraIssue: "EXMPL-0000",
comment: comment,
transitionContext: [
transition: [
name: "Done"
]
]
]
)

when:
service.handle(notification)

then:
1 * jiraService.getIssueTransitions(_) >> new JiraService.IssueTransitions(transitions: [new JiraService.IssueTransitions.Transition(name: "Done", id: "4")])
1 * jiraService.transitionIssue(_, _)
addCommentCall * jiraService.addComment(_, _)

where:
comment || addCommentCall
null || 0
"testing 1234" || 1
}
}

0 comments on commit 8898b68

Please sign in to comment.