diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/core/RegisteredSystems.java b/src/main/java/de/hftstuttgart/unifiedticketing/core/RegisteredSystems.java index 62882a1832bbe1ea4659d0368b8321cd4f39d437..4332df7df84c6696644d33b5543e49e6a4aafb17 100644 --- a/src/main/java/de/hftstuttgart/unifiedticketing/core/RegisteredSystems.java +++ b/src/main/java/de/hftstuttgart/unifiedticketing/core/RegisteredSystems.java @@ -1,5 +1,6 @@ package de.hftstuttgart.unifiedticketing.core; +import de.hftstuttgart.unifiedticketing.systems.github.GithubTicketSystemBuilder; import de.hftstuttgart.unifiedticketing.systems.gitlab.GitlabTicketSystemBuilder; import java.util.logging.Level; @@ -26,6 +27,14 @@ public class RegisteredSystems return instance; } + /** + * Starts the builder mechanism for a GitHub connection + */ + public GithubTicketSystemBuilder github() + { + return new GithubTicketSystemBuilder(); + } + /** * Starts the builder mechanism for a GitLab connection */ diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/core/TicketSystem.java b/src/main/java/de/hftstuttgart/unifiedticketing/core/TicketSystem.java index d3e3658de7ee943cd177dacd9da08d372da5c5c1..b44b57dc5dbcc30da1849f537b8bd419cc6bd997 100644 --- a/src/main/java/de/hftstuttgart/unifiedticketing/core/TicketSystem.java +++ b/src/main/java/de/hftstuttgart/unifiedticketing/core/TicketSystem.java @@ -1,6 +1,7 @@ package de.hftstuttgart.unifiedticketing.core; import de.hftstuttgart.unifiedticketing.exceptions.AssertionException; +import de.hftstuttgart.unifiedticketing.systems.github.GithubTicketSystem; import de.hftstuttgart.unifiedticketing.systems.gitlab.GitlabTicketSystem; import java.util.HashMap; @@ -57,6 +58,9 @@ public abstract class TicketSystem<T extends Ticket, TS extends TicketSystem, TB switch (matcher.group(2)) { + case "github": + return GithubTicketSystem.fromUri(matcher.group(3)); + case "gitlab": return GitlabTicketSystem.fromUri(matcher.group(3)); diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubFilter.java b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..673fe5f1c27ee87bd70008674d79d231d33873a2 --- /dev/null +++ b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubFilter.java @@ -0,0 +1,259 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.hftstuttgart.unifiedticketing.core.Filter; +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.core.TicketSystem; +import de.hftstuttgart.unifiedticketing.exceptions.*; +import okhttp3.*; + +import java.io.IOException; +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class GithubFilter extends Filter<GithubTicket, GithubFilter> +{ + private final static Logger logger = Logging.getLogger(GithubFilter.class.getName()); + + protected final GithubTicketSystem parent; + + protected GithubFilter(GithubTicketSystem parent) + { + this.parent = parent; + } + + protected OkHttpClient getHttpClient() { return new OkHttpClient(); } + + @Override + public List<GithubTicket> get() + { + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + Request.Builder requestBuilder = new Request.Builder() + .url(parent.baseUrl) + .addHeader("accept", parent.acceptHeader) + .get(); + + if (parent.username != null && parent.apiKey != null) + { + requestBuilder.addHeader("Authorization", Credentials.basic(parent.username, parent.apiKey)); + logger.log(Level.FINEST, "added token authentication header"); + } + + HttpUrl.Builder urlBuilder = requestBuilder.build().url().newBuilder(); + + for (Map.Entry<String, Object> mapEntry : setFilters.entrySet()) + { + String f = mapEntry.getKey(); + Object v = mapEntry.getValue(); + try + { + if (f.equals(FilterNames.ASSIGNEEID.name())) + { + logger.log(Level.WARNING, "assignee-id check only possible after request |" + + " Filter: " + FilterNames.ASSIGNEEID.name()); + } else if (f.equals(FilterNames.ASSIGNEENAME.name())) + { + Set<String> names = (Set<String>) v; + if (names.size() > 0) + { + urlBuilder.addQueryParameter("assignee", names.stream() + .findFirst() + .get()); + + if (names.size() > 1) + { + logger.log(Level.WARNING, "assignee-name filter natively only for one name supported"); + } + } + } else if (f.equals(FilterNames.DESCRIPTION_CONTAIN.name())) + { + logger.log(Level.FINE, "Description contain check only possible after request |" + + " Filter: " + FilterNames.DESCRIPTION_CONTAIN.name()); + } else if (f.equals(FilterNames.DESCRIPTION_MATCH.name())) + { + logger.log(Level.FINE, "Regex matching only possible after request |" + + " Filter: " + FilterNames.DESCRIPTION_MATCH.name()); + } else if (f.equals(FilterNames.IDS.name())) + { + logger.log(Level.FINE, "Ticket id matching only possible after request |" + + " Filter: " + FilterNames.IDS.name()); + } else if (f.equals(FilterNames.LABELS.name())) + { + urlBuilder.addQueryParameter("labels", ((Set<String>) v).stream() + .reduce((l1, l2) -> l1 + "," + l2) + .orElse("")); + } else if (f.equals(FilterNames.PAGE.name())) + { + urlBuilder.addQueryParameter("page", String.valueOf(v)); + } else if (f.equals(FilterNames.PAGINATION.name())) + { + urlBuilder.addQueryParameter("per_page", String.valueOf(v)); + } else if (f.equals(FilterNames.OPEN.name())) + { + urlBuilder.addQueryParameter("state", ((boolean) v) ? "open" : "closed"); + } else if (f.equals(FilterNames.TITLE_CONTAINS.name())) + { + logger.log(Level.FINE, "title contain check only possible after request |" + + " Filter: " + FilterNames.TITLE_CONTAINS.name()); + } else if (f.equals(FilterNames.TITLE_MATCH.name())) + { + logger.log(Level.FINE, "Regex matching only possible after request |" + + " Filter: " + FilterNames.TITLE_MATCH.name()); + } else + { + logger.log(Level.WARNING, String.format("unrecognized filter key: %s", f)); + } + } + catch (ClassCastException e) + { + logger.log(Level.SEVERE, "Filter with key " + + f + + " unexpectedly had type " + + v.getClass().getName()); + if (!this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) + { + throw new AssertionException(e); + } + } + } + + requestBuilder.url(urlBuilder.build()); + + OkHttpClient client = getHttpClient(); + Response response; + + try + { + Request request = requestBuilder.build(); + logger.log(Level.FINEST, String.format( + "created request:\n%s", + (this.parent.apiKey != null) + ? request.toString().replace(this.parent.apiKey, "SECRET") + : request.toString() + )); + + response = client.newCall(request).execute(); + } + catch (IOException e) + { + logger.log(Level.SEVERE, String.format("get request FAILED with: %s", e.getMessage())); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpReqeustException(e); + } + + if (response.code() >= 400) + { + logger.log(Level.SEVERE, String.format( + "request failed with response code %d", + response.code() + )); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException( + String.format("ticket query failed, error response code: %d", response.code()), + response.code()); + } + + logger.log(Level.FINEST, "response received\n"); + + ResponseBody responseBody; + responseBody = response.body(); + + if (responseBody == null) + { + logger.log(Level.SEVERE, "query didn't deliver a body in response"); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException("ticket query failed, no response body", response.code()); + } + + List<GithubTicketResponse> tr; + try + { + tr = mapper.readValue(responseBody.bytes(), new TypeReference<List<GithubTicketResponse>>(){}); + + logger.log(Level.FINER, "parsed response body to ticketResponse list instance"); + logger.log(Level.FINEST, String.format("found %d items pre post-filter", tr.size())); + } catch (IOException e) + { + logger.log(Level.SEVERE, String.format("parsing query response FAILED with: %s", e.getMessage())); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new DeserializationException(e); + } + + logger.log(Level.FINER, "starting query post filter"); + Stream<GithubTicketResponse> ticketStream = tr.stream(); + + for (Map.Entry<String, Object> entry : setFilters.entrySet()) + { + String f = entry.getKey(); + Object v = entry.getValue(); + try + { + if (f.equals(FilterNames.ASSIGNEEID.name())) + { + ticketStream = ticketStream.filter(t -> t.assignees.stream() + .map(a -> String.valueOf(a.id)) + .collect(Collectors.toSet()) + .equals((Set<String>) v)); + } + + else if (f.equals(FilterNames.ASSIGNEENAME.name()) && ((Set) v).size() > 1) + { + ticketStream = ticketStream.filter(t -> t.assignees.stream() + .map(a -> a.login) + .collect(Collectors.toSet()) + .equals((Set<String>) v)); + } + + else if (f.equals(FilterNames.DESCRIPTION_CONTAIN)) + { + ticketStream = ticketStream.filter(t -> t.body + .toLowerCase() + .contains(((String) v).toLowerCase())); + } + + else if (f.equals(FilterNames.DESCRIPTION_MATCH.name())) + { + ticketStream = ticketStream.filter(t -> t.body.matches((String) v)); + } + + else if (f.equals(FilterNames.TITLE_CONTAINS.name())) + { + ticketStream = ticketStream.filter(t -> t.title + .toLowerCase() + .contains(((String) v).toLowerCase())); + } + + else if (f.equals(FilterNames.TITLE_MATCH.name())) + { + ticketStream = ticketStream.filter(t -> t.title.matches((String) v)); + } + } catch (ClassCastException e) + { + logger.log(Level.SEVERE, "Filter with key " + + f + + " unexpectedly had type " + + v.getClass().getName()); + } + } + + logger.log(Level.FINER, "post-filter finished"); + + List<GithubTicket> ret = ticketStream.map(t -> GithubTicket.fromTicketResponse(parent, t)) + .collect(Collectors.toList()); + logger.log(Level.FINEST, String.format("remaining items: %d", ret.size())); + + return ret; + } + +} diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicket.java b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicket.java new file mode 100644 index 0000000000000000000000000000000000000000..01c12b812275187e78fa25a4f10d89d666f9ea8c --- /dev/null +++ b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicket.java @@ -0,0 +1,286 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.core.Ticket; +import de.hftstuttgart.unifiedticketing.core.TicketSystem; +import de.hftstuttgart.unifiedticketing.exceptions.DeserializationException; +import de.hftstuttgart.unifiedticketing.exceptions.HttpReqeustException; +import de.hftstuttgart.unifiedticketing.exceptions.HttpResponseException; +import de.hftstuttgart.unifiedticketing.exceptions.SerializationException; +import okhttp3.*; + +import java.io.IOException; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +public class GithubTicket extends Ticket<GithubTicketSystem, GithubTicket> +{ + private final static Logger logger = Logging.getLogger(GithubTicket.class.getName()); + + protected static GithubTicket fromTicketResponse(GithubTicketSystem parent, GithubTicketResponse response) + { + GithubTicket ret = new GithubTicket(parent); + ret.description = response.body; + ret.id = String.valueOf(response.number); + ret.open = response.state == null || response.state.equalsIgnoreCase("open"); + ret.title = response.title; + + if (response.assignees != null) + { + ret.assignees = response.assignees.stream() + .map(a -> new GithubTicketAssignee(a.id, a.login)) + .collect(Collectors.toSet()); + } + + if (response.labels != null) + { + ret.labels = response.labels.stream() + .map(l -> l.name) + .collect(Collectors.toSet()); + } + + return ret; + } + + protected GithubTicket(GithubTicketSystem parent) + { + super(parent); + } + + @Override + public GithubTicket addAssignee(String identifier) + { + this.assignees.add(new GithubTicketAssignee(0, identifier)); + this.updatedFields.add(FieldNames.ASSIGNEES.name()); + return this; + } + + @Override + public GithubTicket removeAssignee(String identifier) + { + this.assignees.removeIf(a -> Objects.equals(a.username, identifier)); + this.updatedFields.add(FieldNames.ASSIGNEES.name()); + return null; + } + + protected OkHttpClient getHttpClient() { return new OkHttpClient(); } + + @Override + public GithubTicket save() + { + if (updatedFields.size() == 0) + { + logger.info("No changed fields, no save required"); + return this; + } + + OkHttpClient client = this.getHttpClient(); + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + logger.log(Level.FINEST, String.format( + "[Ticket %s] preparing body for update request", + this.id + )); + + ObjectNode body = mapper.createObjectNode(); + for (String name : this.updatedFields) + { + if (FieldNames.ASSIGNEES.name().equals(name)) + { + ArrayNode arrayNode = body.putArray("assignees"); + this.assignees.forEach(a -> arrayNode.add(a.username)); + } + + else if (FieldNames.DESCRIPTION.name().equals(name)) + { + body.put("body", this.description); + } + + else if (FieldNames.LABELS.name().equals(name)) + { + ArrayNode arrayNode = body.putArray("labels"); + this.labels.forEach(arrayNode::add); + } + + else if (FieldNames.OPEN.name().equals(name)) + { + body.put("state", (this.open) ? "open" : "closed"); + } + + else if (FieldNames.TITLE.name().equals(name)) + { + body.put("title", this.title); + } + + else + { + logger.log(Level.WARNING, String.format( + "[Ticket %s] unknown field %s will be ignored from update", + this.id, + name + )); + continue; + } + + logger.log(Level.FINEST, String.format( + "[Ticket %s] %s added to update", + this.id, + name + )); + } + + logger.log(Level.FINEST, String.format( + "[Ticket %s] request body for update prepared", + this.id + )); + + Request.Builder builder = new Request.Builder() + .url(String.format("%s/%s", this.parent.baseUrl, this.id)) + .addHeader("accept", parent.acceptHeader); + try + { + logger.log(Level.FINEST, String.format( + "[Ticket %s] serializing update request body", + this.id + )); + + String bodyJson = mapper.writeValueAsString(body); + + logger.log(Level.FINEST, String.format( + "[Ticket %s] serialized JSON:\n%s", + this.id, + bodyJson + )); + builder + .patch(RequestBody.create(bodyJson, MediaType.get("application/json"))); + } catch (JsonProcessingException e) + { + logger.log(Level.SEVERE, String.format( + "[Ticket %s] serializing update request body FAILED!", + this.id + )); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new SerializationException(e); + } + + if (parent.username != null && parent.apiKey != null) + { + builder.addHeader("Authorization", Credentials.basic(parent.username, parent.apiKey)); + logger.log(Level.FINEST, String.format( + "[Ticket %s] added token authentication header", + this.id + )); + } + + Response response; + try + { + Request request = builder.build(); + logger.log(Level.FINEST, String.format( + "[Ticket %s] created request:\n%s", + this.id, + (this.parent.apiKey != null) + ? request.toString().replace(this.parent.apiKey, "SECRET") + : request.toString() + )); + + response = client.newCall(request).execute(); + } + catch (IOException e) + { + logger.log(Level.SEVERE, String.format( + "[Ticket %s] update request FAILED", + this.id + )); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpReqeustException(e); + } + + if (response.code() >= 400) + { + logger.log(Level.SEVERE, String.format( + "[Ticket %s] update request failed with response code %d", + this.id, + response.code() + )); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException( + String.format("ticket save failed, error response code: %d", response.code()), + response.code()); + } + + logger.log(Level.FINEST, String.format( + "[Ticket %s] response received", + this.id + )); + + ResponseBody responseBody; + responseBody = response.body(); + + if (responseBody == null) + { + logger.log(Level.SEVERE, String.format( + "[Ticket %s] update request didn't deliver a body in response", + this.id + )); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException("ticket save failed, no response body", response.code()); + } + + GithubTicketResponse ticketResponse; + try + { + ticketResponse = mapper.readValue(responseBody.bytes(), GithubTicketResponse.class); + + logger.log(Level.FINEST, String.format( + "[Ticket %s] parsed response body to ticketResponse instance", + this.id + )); + } catch (IOException e) + { + logger.log(Level.SEVERE, String.format("parsing update response FAILED with: %s", e.getMessage())); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new DeserializationException(e); + } + + this.updatedFields.clear(); + + logger.log(Level.FINEST, String.format( + "[Ticket %s] update mark state reset", + this.id + )); + + return GithubTicket.fromTicketResponse(this.parent, ticketResponse); + } + + /** + * compares a given Object to this one, including all data fields. + * The normal {@link #equals(Object)} method does only compare Ticket Type and id, + * but not the data fields like this one. + * @param o Object to compare to this + * @return true if the given object is equal in terms of type, id and content to this + */ + public boolean deepEquals(Object o) + { + if (!equals(o)) return false; + GithubTicket t = (GithubTicket) o; + + return Objects.equals(title, t.title) + && Objects.equals(description, t.description) + && Objects.equals(labels, t.labels) + && Objects.equals(assignees, t.assignees); + } +} diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketAssignee.java b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketAssignee.java new file mode 100644 index 0000000000000000000000000000000000000000..08e3257c1ddf536077962124aae87b38ea05b91f --- /dev/null +++ b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketAssignee.java @@ -0,0 +1,11 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import de.hftstuttgart.unifiedticketing.core.TicketAssignee; + +public class GithubTicketAssignee extends TicketAssignee +{ + protected GithubTicketAssignee(int id, String username) + { + super(null, String.valueOf(id), username, null); + } +} diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketBuilder.java b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketBuilder.java new file mode 100644 index 0000000000000000000000000000000000000000..4c4b00aa6343fce51615183e0f2bed0679cfe265 --- /dev/null +++ b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketBuilder.java @@ -0,0 +1,160 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.core.TicketBuilder; +import de.hftstuttgart.unifiedticketing.core.TicketSystem; +import de.hftstuttgart.unifiedticketing.exceptions.*; +import okhttp3.*; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class GithubTicketBuilder extends TicketBuilder<GithubTicketBuilder, GithubTicket, GithubTicketSystem> +{ + private final static Logger logger = Logging.getLogger(GithubTicketBuilder.class.getName()); + + protected GithubTicketBuilder(GithubTicketSystem parent) + { + super(parent); + } + + protected OkHttpClient getHttpClient() { return new OkHttpClient(); } + + @Override + public GithubTicket create() + { + logger.log(Level.FINEST, "starting Ticket creation from builder data"); + + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + ObjectNode body = mapper.createObjectNode(); + + // title is mandatory field + if (this.title == null) + { + String msg = "mandatory field title not set before building ticket"; + logger.log(Level.SEVERE, msg); + + if (parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new AssertionException(msg); + } + + if (this.description != null) + { + body.put("body", this.description); + logger.log(Level.FINEST, "description set"); + } + + body.put("title", this.title); + logger.log(Level.FINEST, "title set"); + + if (this.assignees != null) + { + ArrayNode arrayNode = body.putArray("assignees"); + assignees.forEach(arrayNode::add); + logger.log(Level.FINEST, "assignees set"); + } + + if (this.labels != null) + { + ArrayNode arrayNode = body.putArray("labels"); + this.labels.forEach(arrayNode::add); + logger.log(Level.FINEST, "labels set"); + } + + String jsonBody; + + try + { + jsonBody = mapper.writeValueAsString(body); + } catch (JsonProcessingException e) + { + String msg = "json serialization FAILED"; + logger.log(Level.SEVERE, msg); + + if (parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new SerializationException(e); + } + + OkHttpClient client = getHttpClient(); + + Request.Builder builder = new Request.Builder() + .url(parent.baseUrl) + .addHeader("accept", parent.acceptHeader) + .post(RequestBody.create(jsonBody, MediaType.get("application/json"))); + + if (this.parent.username != null && this.parent.apiKey != null) + { + builder.addHeader("Authorization", Credentials.basic(this.parent.username, this.parent.apiKey)); + logger.log(Level.FINEST, "added basic auth header with api token"); + } + + Response response; + try + { + Request request = builder.build(); + logger.log(Level.FINEST, String.format( + "created request:\n%s", + (this.parent.apiKey != null) + ? request.toString().replace(this.parent.apiKey, "SECRET") + : request.toString() + )); + + response = client.newCall(request).execute(); + } catch (IOException e) + { + logger.log(Level.SEVERE, "create request FAILED"); + + if (parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpReqeustException(e); + } + + if (response.code() >= 400) + { + logger.log(Level.SEVERE, String.format( + "create request failed with response code %d", + response.code() + )); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException( + String.format("ticket creation failed, error response code %d", response.code()), + response.code()); + } + + logger.log(Level.FINEST, "response received\n"); + + ResponseBody responseBody; + responseBody = response.body(); + + if (responseBody == null) + { + logger.log(Level.SEVERE, "create request didn't deliver a body in response"); + + if (this.parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException("ticket creation failed, no response body", response.code()); + } + + GithubTicketResponse ticketResponse; + try + { + ticketResponse = mapper.readValue(responseBody.bytes(), GithubTicketResponse.class); + logger.log(Level.FINEST, "parsed response to ticketResponse instance"); + } catch (IOException e) + { + logger.severe(String.format("parsing create response FAILED with: %s", e.getMessage())); + + if (parent.getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new DeserializationException(e); + } + + return GithubTicket.fromTicketResponse(parent, ticketResponse); + } +} diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketResponse.java b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketResponse.java new file mode 100644 index 0000000000000000000000000000000000000000..478036f212dc2d1d762ae17c6f354c229f8fe3cc --- /dev/null +++ b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketResponse.java @@ -0,0 +1,24 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import java.util.Set; + +public class GithubTicketResponse +{ + public Set<Assignee> assignees; + public String body; + public int number; + public Set<Label> labels; + public String state; + public String title; + + protected static class Label + { + public String name; + } + + protected static class Assignee + { + public int id; + public String login; + } +} diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystem.java b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystem.java new file mode 100644 index 0000000000000000000000000000000000000000..ac791830060327a71ec65698fbd187b528e19347 --- /dev/null +++ b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystem.java @@ -0,0 +1,229 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.hftstuttgart.unifiedticketing.core.*; +import de.hftstuttgart.unifiedticketing.exceptions.*; +import okhttp3.*; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class GithubTicketSystem extends TicketSystem<GithubTicket, GithubTicketSystem, GithubTicketBuilder, GithubFilter> +{ + private final static Logger logger = Logging.getLogger(GithubTicketSystem.class.getName()); + + protected final String acceptHeader; + protected final String apiKey; + protected final String username; + + /** + * creates a new instance of this class from an uri.<br> + * <br> + * <b>Attention:</b> This method should not be called from the enduser!<br> + * Instead call the same method from {@link TicketSystem}.<br> + * To call this method directly, the uri has to be shortend by the {@code "unifiedticketing:github:"} part. + * @param uri gitlab specific part of full uri + * @return new github ticket system instance + */ + public static GithubTicketSystem fromUri(String uri) + { + Matcher matcher = Pattern.compile("^((http|https):\\/\\/)?(.*?(:[0-9]+.*?)?)::(.*?):(.*?)(:(.*?):(.*?))?$").matcher(uri); + + if (!matcher.matches()) + { + String msg = "uri didn't match regex"; + logger.log(Level.SEVERE, msg); + throw new AssertionException(msg); + } + + GithubTicketSystemBuilder builder = new GithubTicketSystemBuilder(); + + if (matcher.group(2) != null && matcher.group(2).length() > 0) + { + if (matcher.group(2).equalsIgnoreCase("http")) builder.withHttp(); + else builder.withHttps(); + } + + if (matcher.group(3) == null || matcher.group(3).length() == 0) + { + String msg = "no base url identified in uri"; + throw new AssertionException(msg); + } + builder.withBaseUrl(matcher.group(3)); + + if (matcher.group(5) == null || matcher.group(5).length() == 0) + { + String msg = "no owner identified in uri"; + throw new AssertionException(msg); + } + builder.withOwner(matcher.group(5)); + + if (matcher.group(6) == null || matcher.group(6).length() == 0) + { + String msg = "no repo identified in uri"; + throw new AssertionException(msg); + } + builder.withRepo(matcher.group(6)); + + if (matcher.group(8) != null && matcher.group(9) != null) + { + builder.withAuthentication(matcher.group(8), matcher.group(9)); + } + else logger.log(Level.INFO, "no authentication given, creating anonymous instance"); + + return builder.build(); + } + + protected GithubTicketSystem(String acceptHeader, String baseUrl) + { + this(acceptHeader, baseUrl, null, null); + } + + protected GithubTicketSystem(String acceptHeader, String baseUrl, String username, String apiKey) + { + super(); + + this.acceptHeader = acceptHeader; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.username = username; + } + + /** + * starts builder process for new github ticket + */ + @Override + public GithubTicketBuilder createTicket() + { + return new GithubTicketBuilder(this); + } + + @Override + public GithubFilter find() + { + return new GithubFilter(this); + } + + protected OkHttpClient getHttpClient() { return new OkHttpClient(); } + + @Override + public GithubTicket getTicketById(String id) + { + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + Request.Builder requestBuilder = new Request.Builder() + .url(String.format("%s/%s", baseUrl, id)) + .addHeader("accept", acceptHeader) + .get(); + + if (username != null && apiKey != null) + { + requestBuilder.addHeader("Authorization", Credentials.basic(username, apiKey)); + logger.log(Level.FINEST, "added token authentication header"); + } + + HttpUrl.Builder urlBuilder = requestBuilder.build().url().newBuilder(); + + requestBuilder.url(urlBuilder.build()); + + OkHttpClient client = getHttpClient(); + Response response; + + try + { + Request request = requestBuilder.build(); + logger.log(Level.FINEST, String.format( + "created request:\n%s", + (apiKey != null) + ? request.toString().replace(apiKey, "SECRET") + : request.toString() + )); + + response = client.newCall(request).execute(); + } + catch (IOException e) + { + logger.log(Level.SEVERE, String.format("get request FAILED with: %s", e.getMessage())); + + if (getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpReqeustException(e); + } + + if (response.code() >= 400) + { + logger.log(Level.SEVERE, String.format( + "request failed with response code %d", + response.code() + )); + + if (getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException( + String.format("ticket query failed, error response code: %d", response.code()), + response.code()); + } + + logger.log(Level.FINEST, "response received\n"); + + ResponseBody responseBody; + responseBody = response.body(); + + if (responseBody == null) + { + logger.log(Level.SEVERE, "query didn't deliver a body in response"); + + if (getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new HttpResponseException("ticket query failed, no response body", response.code()); + } + + GithubTicketResponse tr; + try + { + tr = mapper.readValue(responseBody.bytes(), GithubTicketResponse.class); + + logger.log(Level.FINER, "parsed response body to ticketResponse instance"); + } catch (IOException e) + { + logger.log(Level.SEVERE, String.format("parsing query response FAILED with: %s", e.getMessage())); + + if (getConfigTrue(TicketSystem.ConfigurationOptions.RETURN_NULL_ON_ERROR)) return null; + else throw new DeserializationException(e); + } + + return GithubTicket.fromTicketResponse(this, tr); + } + + @Override + public boolean hasAssigneeSupport() + { + return true; + } + + @Override + public boolean hasDefaultPagination() + { + return true; + } + + @Override + public boolean hasLabelSupport() + { + return true; + } + + @Override + public boolean hasPaginationSupport() + { + return true; + } + + @Override + public boolean hasReturnNullOnErrorSupport() + { + return true; + } +} diff --git a/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemBuilder.java b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemBuilder.java new file mode 100644 index 0000000000000000000000000000000000000000..1ec93eed92fb4d19a545cd342c903832a5236510 --- /dev/null +++ b/src/main/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemBuilder.java @@ -0,0 +1,94 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.core.TicketSystemBuilder; +import de.hftstuttgart.unifiedticketing.exceptions.AssertionException; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class GithubTicketSystemBuilder extends TicketSystemBuilder<GithubTicketSystemBuilder, GithubTicketSystem> +{ + private static final Logger logger = Logging.getLogger(GithubTicketSystemBuilder.class.getName()); + + protected String acceptHeader; + protected String apiKey; + protected boolean https; + protected String owner; + protected String repo; + protected String username; + + /** + * starts builder process for a {@link GithubTicketSystem} instance + */ + public GithubTicketSystemBuilder() + { + super(); + + acceptHeader = "application/vnd.github.v3+json"; + https = true; + + logger.log(Level.FINEST, "Builder for Github System instance started"); + } + + public GithubTicketSystemBuilder withAuthentication(String username, String apiKey) + { + this.username = username; + this.apiKey = apiKey; + logger.log(Level.FINEST, "set authentication values"); + return this; + } + + public GithubTicketSystemBuilder withHttp() + { + this.https = false; + logger.log(Level.FINEST, "set http"); + return this; + } + + public GithubTicketSystemBuilder withHttps() + { + this.https = true; + logger.log(Level.FINEST, "set https"); + return this; + } + + public GithubTicketSystemBuilder withOwner(String owner) + { + this.owner = owner; + logger.log(Level.FINEST, String.format("set owner to %s", this.owner)); + return this; + } + + public GithubTicketSystemBuilder withRepo(String repo) + { + this.repo = repo; + logger.log(Level.FINEST, String.format("set repo to %s", this.repo)); + return this; + } + + @Override + public GithubTicketSystem build() + { + if (baseUrl == null + || owner == null + || repo == null) + { + String msg = "one of mandatory fields 'baseUrl', 'owner' or 'repo' missing!"; + throw new AssertionException(msg); + } + + return new GithubTicketSystem( + acceptHeader, + String.format( + "%s://%s/repos/%s/%s/issues", + (https) ? "https" : "http", + baseUrl, + owner, + repo + ), + username, + apiKey + ); + } +} diff --git a/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubFilterTest.java b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubFilterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..b73fa636d7daab6351206eafde6b5bc2178e7fda --- /dev/null +++ b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubFilterTest.java @@ -0,0 +1,225 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.exceptions.DeserializationException; +import de.hftstuttgart.unifiedticketing.exceptions.HttpResponseException; +import okhttp3.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.*; +import java.util.logging.Level; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class GithubFilterTest +{ + public GithubFilter instance; + public Call call; + public ArgumentCaptor<Request> requestCaptor; + public Response.Builder responseBuilder; + + @BeforeAll + public static void initBeforeAll() + { + Logging.setLevel(Level.ALL); + } + + @BeforeEach + public void initBeforeEach() + { + GithubTicketSystem parent = + spy(new GithubTicketSystem("someHeader", "https://api.github.com")); + instance = spy(new GithubFilter(parent)); + + OkHttpClient client = mock(OkHttpClient.class); + call = mock(Call.class); + requestCaptor = ArgumentCaptor.forClass(Request.class); + responseBuilder = new Response.Builder() + .request(new Request.Builder().url("http://test.some.tld/").build()) + .protocol(Protocol.HTTP_1_1) + .message("some message"); + + doReturn(client).when(instance).getHttpClient(); + doReturn(call).when(client).newCall(requestCaptor.capture()); + } + + @Test + public void testGetServerError() throws IOException + { + doReturn(responseBuilder.code(500).build()).when(call).execute(); + assertThrows(HttpResponseException.class, () -> instance.get()); + } + + @Test + public void testGetClientError() throws IOException + { + doReturn(responseBuilder.code(400).build()).when(call).execute(); + assertThrows(HttpResponseException.class, () -> instance.get()); + } + + @Test + public void testGetNullBody() throws IOException + { + doReturn(responseBuilder.code(200).build()).when(call).execute(); + assertThrows(HttpResponseException.class, () -> instance.get()); + } + + @Test + public void testGetNoJsonBody() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("somestrangething", MediaType.get("application/json"))) + .build()) + .when(call).execute(); + assertThrows(DeserializationException.class, () -> instance.get()); + } + + @Test + public void testGetRequestParamsPart1() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("[]".getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + + String[] values = new String[] + { + "bug", + "label1", + "2", + "50", + }; + + instance + .withLabel(values[0]) + .withLabel(values[1]) + .setPage(Integer.parseInt(values[2])) + .setPageSize(Integer.parseInt(values[3])) + .isOpen() + .get(); + + HttpUrl url = requestCaptor.getValue().url(); + assertAll( + () -> assertEquals(String.format("%s,%s", values[0], values[1]), url.queryParameter("labels")), + () -> assertEquals(values[2], url.queryParameter("page")), + () -> assertEquals(values[3], url.queryParameter("per_page")), + () -> assertEquals("open", url.queryParameter("state")) + ); + } + + @Test + public void testGetRequestParamsPart2() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("[]".getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + + instance + .isClosed() + .get(); + + HttpUrl url = requestCaptor.getValue().url(); + assertAll( + () -> assertEquals("closed", url.queryParameter("state")) + ); + } + + @Test + public void testGetLocalFilter() throws IOException + { + GithubTicketResponse res = new GithubTicketResponse(); + res.number = 5; + res.title = "some title"; + res.body = "descriptive text"; + res.assignees = new HashSet<>(); + res.state = "open"; + res.labels = Arrays.stream(new String[]{"unifiedticketing", "bug"}) + .map(l -> { + GithubTicketResponse.Label label = new GithubTicketResponse.Label(); + label.name = l; + return label; + }) + .collect(Collectors.toSet()); + + ObjectMapper mapper = new ObjectMapper(); + ArrayNode arrayNode = mapper.createArrayNode(); + arrayNode.add(mapper.valueToTree(res)); + + res.number = 8; + res.title = "some special title"; + arrayNode.add(mapper.valueToTree(res)); + + res.number = 94; + res.body = "description with @username marked"; + arrayNode.add(mapper.valueToTree(res)); + + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create(arrayNode.toString().getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + + List<GithubTicket> result = instance + .withTitleMatch("^.*special.*$") + .withDescriptionMatch("^.*@username.*$") + .get(); + + assertAll( + () -> assertEquals(1, result.size()), + () -> Assertions.assertEquals("94", result.get(0).getId()) + ); + } + + @Test + public void testGetDeserialization() throws IOException + { + GithubTicketResponse res = new GithubTicketResponse(); + res.number = 99; + res.title = "some title"; + res.body = "descriptive text"; + res.assignees = new HashSet<>(); + res.state = "open"; + res.labels = Arrays.stream(new String[]{"unifiedticketing", "feature-request"}) + .map(l -> { + GithubTicketResponse.Label label = new GithubTicketResponse.Label(); + label.name = l; + return label; + }) + .collect(Collectors.toSet()); + + ObjectMapper mapper = new ObjectMapper(); + ArrayNode arrayNode = mapper.createArrayNode(); + arrayNode.add(mapper.valueToTree(res)); + + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create(arrayNode.toString().getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + + List<GithubTicket> expected = new LinkedList<>( + Collections.singleton(GithubTicket.fromTicketResponse(instance.parent, res))); + List<GithubTicket> actual = instance.get(); + + assertEquals(expected, actual); + assertTrue(expected.get(0).deepEquals(actual.get(0))); + } +} diff --git a/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketBuilderTest.java b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketBuilderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..265b2399cd091e962a2a1defabd5c34508a356c9 --- /dev/null +++ b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketBuilderTest.java @@ -0,0 +1,158 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.exceptions.AssertionException; +import de.hftstuttgart.unifiedticketing.exceptions.DeserializationException; +import de.hftstuttgart.unifiedticketing.exceptions.HttpResponseException; +import okhttp3.*; +import okio.Buffer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.logging.Level; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class GithubTicketBuilderTest +{ + public GithubTicketBuilder instance; + public Call call; + public ArgumentCaptor<Request> requestCaptor; + public Response.Builder responseBuilder; + + @BeforeAll + public static void initBeforeAll() + { + Logging.setLevel(Level.ALL); + } + + @BeforeEach + public void initBeforeEach() + { + GithubTicketSystem parent = + spy(new GithubTicketSystem("something", "https://example.org")); + instance = spy(new GithubTicketBuilder(parent)); + + OkHttpClient client = mock(OkHttpClient.class); + call = mock(Call.class); + requestCaptor = ArgumentCaptor.forClass(Request.class); + responseBuilder = new Response.Builder() + .request(new Request.Builder().url("http://test.some.tld/").build()) + .protocol(Protocol.HTTP_1_1) + .message("some message"); + + doReturn(client).when(instance).getHttpClient(); + doReturn(call).when(client).newCall(requestCaptor.capture()); + } + + @Test + public void testSaveNoCreationWithoutMinimumRequirements() + { + assertThrows(AssertionException.class, () -> instance.create()); + verify(instance, never()).getHttpClient(); + } + + @Test + public void testSaveServerError() throws IOException + { + doReturn(responseBuilder.code(500).build()).when(call).execute(); + instance.title("we have a new title"); + assertThrows(HttpResponseException.class, () -> instance.create()); + } + + @Test + public void testSaveClientError() throws IOException + { + doReturn(responseBuilder.code(400).build()).when(call).execute(); + instance.title("we have another new title"); + assertThrows(HttpResponseException.class, () -> instance.create()); + } + + @Test + public void testSaveNullBody() throws IOException + { + doReturn(responseBuilder.code(200).build()).when(call).execute(); + instance.title("we have no body this time"); + assertThrows(HttpResponseException.class, () -> instance.create()); + } + + @Test + public void testSaveNoJsonBody() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("somestrangething", MediaType.get("application/json"))) + .build()) + .when(call).execute(); + instance.title("this body is no json"); + assertThrows(DeserializationException.class, () -> instance.create()); + } + + @Test + public void testSaveSuccessfulUpdate() throws IOException + { + GithubTicketResponse ticketResponse = new GithubTicketResponse(); + ticketResponse.title = "title of ticket"; + ticketResponse.body = "description"; + ticketResponse.number = 5; + ticketResponse.labels = Arrays.stream(new String[]{"unifiedticketing", "feature-request"}) + .map(l -> { + GithubTicketResponse.Label label = new GithubTicketResponse.Label(); + label.name = l; + return label; + }) + .collect(Collectors.toSet()); + GithubTicketResponse.Assignee assignee = new GithubTicketResponse.Assignee(); + assignee.id = 234; + assignee.login = "username"; + ticketResponse.assignees = new HashSet<>(Collections.singleton(assignee)); + + GithubTicket expected = GithubTicket.fromTicketResponse(instance.parent, ticketResponse); + String responseJson = new ObjectMapper().writeValueAsString(ticketResponse); + + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create(responseJson.getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + GithubTicket actual = instance.title("some title").create(); + assertEquals(expected, actual); + assertTrue(expected.deepEquals(actual)); + } + + @Test + public void testSaveRequestJson() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("{}".getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + + instance.title("title of ticket") + .description("description") + .labels(new HashSet<>(Arrays.asList("bug", "unifiedticketing"))) + .assignees("somebody") + .create(); + + Buffer buffer = new Buffer(); + ObjectMapper mapper = new ObjectMapper(); + requestCaptor.getValue().body().writeTo(buffer); + String expectedJson = "{\"assignees\":[\"somebody\"],\"body\":\"description\"," + + "\"title\":\"title of ticket\",\"labels\":[\"bug\",\"unifiedticketing\"]}"; + assertEquals(mapper.readTree(expectedJson), mapper.readTree(buffer.readUtf8())); + } +} diff --git a/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketResponseTest.java b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketResponseTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ce8dac94b8bd8c6d0458ba7f92fd8154164ed39c --- /dev/null +++ b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketResponseTest.java @@ -0,0 +1,48 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.hftstuttgart.unifiedticketing.core.Logging; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +public class GithubTicketResponseTest +{ + @BeforeAll + public static void initBeforeAll() + { + Logging.setLevel(Level.ALL); + } + + @Test + public void testDeserialization() throws IOException + { + ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + String[] values = new String[] + // title descr iid label label userid username + {"title of ticket", "description", "5", "bug", "unifiedticketing", "234", "username"}; + String responseJson = String.format( + "{\"title\":\"%s\",\"body\":\"%s\",\"number\":%s," + + "\"labels\":[{\"name\":\"%s\"},{\"name\":\"%s\"}],\"assignees\":[{\"id\":%s,\"login\":\"%s\"}]}", + (Object[]) values); + + GithubTicketResponse response = mapper.readValue(responseJson.getBytes(), GithubTicketResponse.class); + assertAll( + () -> assertEquals(values[0], response.title), + () -> assertEquals(values[1], response.body), + () -> assertEquals(values[2], String.valueOf(response.number)), + () -> assertTrue(response.labels.stream().map(l -> l.name).collect(Collectors.toSet()).contains(values[3])), + () -> assertTrue(response.labels.stream().map(l -> l.name).collect(Collectors.toSet()).contains(values[4])), + () -> assertEquals(1, response.assignees.size()), + () -> assertEquals(values[5], String.valueOf(response.assignees.stream().findFirst().get().id)), + () -> assertEquals(values[6], response.assignees.stream().findFirst().get().login) + ); + } +} diff --git a/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemBuilderTest.java b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemBuilderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ef5199a075226cbf147544cd9ef6e2759127c6c7 --- /dev/null +++ b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemBuilderTest.java @@ -0,0 +1,120 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.exceptions.AssertionException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.logging.Level; +import java.util.regex.Pattern; + +public class GithubTicketSystemBuilderTest +{ + public GithubTicketSystemBuilder instance; + + @BeforeAll + public static void initBeforeAll() + { + Logging.setLevel(Level.ALL); + } + + @BeforeEach + public void initBeforeEach() + { + instance = Mockito.spy(new GithubTicketSystemBuilder()); + } + + @Test + public void testInit() + { + assertAll( + () -> assertEquals("application/vnd.github.v3+json", instance.acceptHeader), + () -> assertNull(instance.apiKey), + () -> assertNull(instance.owner), + () -> assertNull(instance.repo), + () -> assertTrue(instance.https), + () -> assertNull(instance.username) + ); + } + + @Test + public void testWithApiKey() + { + String username = "someUser"; + String apiKey = "asoashdboahasjhdfbeuazhvfef4q34v3h4v435v2"; + instance.withAuthentication(username, apiKey); + assertAll( + () -> assertEquals(username, instance.username), + () -> assertEquals(apiKey, instance.apiKey) + ); + } + + @Test + public void testWithHttp() + { + instance.https = true; + instance.withHttp(); + + assertFalse(instance.https); + } + + @Test + public void testWithHttps() + { + instance.https = false; + instance.withHttps(); + assertTrue(instance.https); + } + + @Test + public void testWithProjectIdByInt() + { + String expected = "255254123"; + instance.withRepo(expected); + + assertEquals(expected, instance.repo); + } + + @Test + public void testBuild() + { + instance.withBaseUrl("blabla.tld"); + assertThrows(AssertionException.class, () -> instance.build()); + + String apiKey = "aaesfsef32qqfq3f"; + String baseUrl = "api.github.com"; + String owner = "someOwner"; + String repo = "42"; + String username = "someUsername"; + instance = new GithubTicketSystemBuilder(); + instance.withOwner(owner); + instance.withRepo(repo); + assertThrows(AssertionException.class, () -> instance.build()); + + instance = new GithubTicketSystemBuilder(); + String regex = "^%s://%s/repos/%s/%s/issues$"; + Pattern pattern = Pattern.compile( + String.format(regex, "https", baseUrl, owner, repo)); + + instance.withBaseUrl(baseUrl) + .withOwner(owner) + .withRepo(repo); + GithubTicketSystem system = instance.build(); + assertTrue(pattern.matcher(system.baseUrl).matches()); + + pattern = Pattern.compile(String.format(regex, "http", baseUrl, owner, repo)); + instance + .withHttp() + .withAuthentication(username, apiKey); + system = instance.build(); + assertTrue(pattern.matcher(system.baseUrl).matches()); + assertEquals(username, system.username); + assertEquals(apiKey, system.apiKey); + } +} diff --git a/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemTest.java b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e8ee03a995566abc5c3ae49c49703ecf697145fd --- /dev/null +++ b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketSystemTest.java @@ -0,0 +1,198 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.core.TicketSystem; +import de.hftstuttgart.unifiedticketing.exceptions.AssertionException; +import de.hftstuttgart.unifiedticketing.exceptions.DeserializationException; +import de.hftstuttgart.unifiedticketing.exceptions.HttpResponseException; +import okhttp3.*; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.*; +import java.util.logging.Level; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class GithubTicketSystemTest +{ + public GithubTicketSystem instance; + public Call call; + public ArgumentCaptor<Request> requestCaptor; + public Response.Builder responseBuilder; + + @BeforeAll + public static void initBeforeAll() + { + Logging.setLevel(Level.ALL); + } + + @BeforeEach + public void initBeforeEach() + { + instance = spy(new GithubTicketSystem("someHeader", "https://api.github.com")); + + OkHttpClient client = mock(OkHttpClient.class); + call = mock(Call.class); + requestCaptor = ArgumentCaptor.forClass(Request.class); + responseBuilder = new Response.Builder() + .request(new Request.Builder().url("http://test.some.tld/").build()) + .protocol(Protocol.HTTP_1_1) + .message("some message"); + + doReturn(client).when(instance).getHttpClient(); + doReturn(call).when(client).newCall(requestCaptor.capture()); + } + + @Test + public void testFromUri() + { + String base = "api.github.com"; + String owner = "myOwner"; + String repo = "myRepo"; + String username = "someUser"; + String apikey = "afdaf3aqraf3afafmyxcbvmyxvbas3wrawra"; + GithubTicketSystem actual = (GithubTicketSystem) TicketSystem.fromUri(buildTestUri(true, base, owner, repo, username, apikey)); + + assertEquals(buildFinalBaseurl(true, base, owner, repo), actual.baseUrl); + assertEquals(username, actual.username); + assertEquals(apikey, actual.apiKey); + assertEquals(GithubTicketSystem.class, actual.getClass()); + + base = "api.github.somedomain.tld:8080"; + owner = "otherOwner"; + repo = "otherRepo"; + username = "me"; + apikey = "a34adfae3ra3rasd"; + actual = (GithubTicketSystem) TicketSystem.fromUri(buildTestUri(false, base, owner, repo, username, apikey)); + + assertEquals(buildFinalBaseurl(false, base, owner, repo), actual.baseUrl); + assertEquals(username, actual.username); + assertEquals(apikey, actual.apiKey); + assertEquals(GithubTicketSystem.class, actual.getClass()); + + actual = (GithubTicketSystem) TicketSystem.fromUri(buildTestUri(false, base, owner, repo, null, null)); + assertEquals(buildFinalBaseurl(false, base, owner, repo), actual.baseUrl); + assertNull(actual.username); + assertNull(actual.apiKey); + assertEquals(GithubTicketSystem.class, actual.getClass()); + + assertThrows(AssertionException.class, () -> TicketSystem.fromUri("unifiedticketing:github:blablabal")); + } + + @Test + public void testCreateTicket() + { + assertEquals(GithubTicketBuilder.class, instance.createTicket().getClass()); + assertSame(instance, instance.createTicket().parent); + } + + @Test + public void testFind() + { + assertEquals(GithubFilter.class, instance.find().getClass()); + assertSame(instance, instance.find().parent); + } + + @Test + public void testGetServerError() throws IOException + { + doReturn(responseBuilder.code(500).build()).when(call).execute(); + assertThrows(HttpResponseException.class, () -> instance.getTicketById("bla")); + } + + @Test + public void testGetClientError() throws IOException + { + doReturn(responseBuilder.code(400).build()).when(call).execute(); + assertThrows(HttpResponseException.class, () -> instance.getTicketById("bla")); + } + + @Test + public void testGetNullBody() throws IOException + { + doReturn(responseBuilder.code(200).build()).when(call).execute(); + assertThrows(HttpResponseException.class, () -> instance.getTicketById("bla")); + } + + @Test + public void testGetNoJsonBody() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("somestrangething", MediaType.get("application/json"))) + .build()) + .when(call).execute(); + assertThrows(DeserializationException.class, () -> instance.getTicketById("bla")); + } + + @Test + public void testGetTicketById() throws IOException + { + GithubTicketResponse res = new GithubTicketResponse(); + res.number = 99; + res.title = "some title"; + res.body = "descriptive text"; + res.assignees = new HashSet<>(); + res.state = "open"; + res.labels = Arrays.stream(new String[]{"unifiedticketing", "feature-request"}) + .map(l -> { + GithubTicketResponse.Label label = new GithubTicketResponse.Label(); + label.name = l; + return label; + }) + .collect(Collectors.toSet()); + + ObjectMapper mapper = new ObjectMapper(); + + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create(mapper.writeValueAsString(res), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + + GithubTicket expected = GithubTicket.fromTicketResponse(instance, res); + GithubTicket actual = instance.getTicketById(res.number); + + assertEquals(expected, actual); + assertTrue(expected.deepEquals(actual)); + } + + @Test + public void testSupport() + { + assertAll( + () -> assertTrue(instance.hasAssigneeSupport()), + () -> assertTrue(instance.hasDefaultPagination()), + () -> assertTrue(instance.hasLabelSupport()), + () -> assertTrue(instance.hasPaginationSupport()), + () -> assertTrue(instance.hasReturnNullOnErrorSupport()) + ); + } + + private static String buildTestUri(boolean https, String base, String owner, String repo, String username, String apikey) + { + return String.format( + "unifiedticketing:github:%s://%s::%s:%s%s", + (https) ? "https": "http", + base, + owner, + repo, + (username == null || apikey == null) ? "" : ":" + username + ":" + apikey); + } + + private static String buildFinalBaseurl(boolean https, String base, String owner, String repo) + { + return String.format("%s://%s/repos/%s/%s/issues", (https) ? "https" : "http", base, owner, repo); + } +} diff --git a/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketTest.java b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketTest.java new file mode 100644 index 0000000000000000000000000000000000000000..716a892c178d80319d82d39b8a57d9adea374afc --- /dev/null +++ b/src/test/java/de/hftstuttgart/unifiedticketing/systems/github/GithubTicketTest.java @@ -0,0 +1,195 @@ +package de.hftstuttgart.unifiedticketing.systems.github; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.hftstuttgart.unifiedticketing.core.Logging; +import de.hftstuttgart.unifiedticketing.exceptions.AssertionException; +import de.hftstuttgart.unifiedticketing.exceptions.DeserializationException; +import de.hftstuttgart.unifiedticketing.exceptions.HttpResponseException; +import okhttp3.*; +import okio.Buffer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.io.IOException; +import java.util.*; +import java.util.logging.Level; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class GithubTicketTest +{ + public GithubTicket instance; + public Call call; + public ArgumentCaptor<Request> requestCaptor; + public Response.Builder responseBuilder; + + @BeforeAll + public static void initBeforeAll() + { + Logging.setLevel(Level.ALL); + } + + @BeforeEach + public void initBeforeEach() + { + instance = + spy(new GithubTicket( + spy(new GithubTicketSystem("someHeader", "https://example.org")))); + + OkHttpClient client = mock(OkHttpClient.class); + call = mock(Call.class); + requestCaptor = ArgumentCaptor.forClass(Request.class); + responseBuilder = new Response.Builder() + .request(new Request.Builder().url("http://test.some.tld/").build()) + .protocol(Protocol.HTTP_1_1) + .message("some message"); + + doReturn(client).when(instance).getHttpClient(); + doReturn(call).when(client).newCall(requestCaptor.capture()); + } + + @Test + public void testFromTicketResponse() + { + GithubTicketResponse res = new GithubTicketResponse(); + GithubTicket ticket = GithubTicket.fromTicketResponse(instance.getParent(), res); + + assertAll( + () -> assertSame(instance.getParent(), ticket.getParent()), + () -> assertNull(ticket.getTitle()), + () -> assertNull(ticket.getDescription()), + () -> assertEquals("0", ticket.getId()), + () -> assertNotNull(ticket.getLabels()), + () -> assertTrue(ticket.isOpen()), + () -> assertNull(ticket.getTitle()), + () -> assertNotNull(ticket.getAssignees()) + ); + } + + @Test + public void testAddAssigneeByString() + { + String username = "someUsername"; + instance.addAssignee(username); + assertEquals(1, instance.getAssignees().size()); + assertEquals(username, instance.getAssignees().stream().findFirst().get().username); + } + + @Test + public void testRemoveAssigneeByString() + { + // first add assignee and check it was added properly + testAddAssigneeByString(); + + String username = instance.getAssignees().stream().findFirst().get().username; + instance.removeAssignee(username); + assertEquals(0, instance.getAssignees().size()); + } + + @Test + public void testSaveNoHttpClientWithoutChanges() + { + GithubTicket actual = instance.save(); + verify(instance, never()).getHttpClient(); + assertSame(instance, actual); + } + + @Test + public void testSaveServerError() throws IOException + { + doReturn(responseBuilder.code(500).build()).when(call).execute(); + instance.setTitle("we have a new title"); + assertThrows(HttpResponseException.class, () -> instance.save()); + } + + @Test + public void testSaveClientError() throws IOException + { + doReturn(responseBuilder.code(400).build()).when(call).execute(); + instance.setTitle("we have another new title"); + assertThrows(HttpResponseException.class, () -> instance.save()); + } + + @Test + public void testSaveNullBody() throws IOException + { + doReturn(responseBuilder.code(200).build()).when(call).execute(); + instance.setTitle("we have no body this time"); + assertThrows(HttpResponseException.class, () -> instance.save()); + } + + @Test + public void testSaveNoJsonBody() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("somestrangething", MediaType.get("application/json"))) + .build()) + .when(call).execute(); + instance.setTitle("this body is no json"); + assertThrows(DeserializationException.class, () -> instance.save()); + } + + @Test + public void testSaveSuccessfulUpdate() throws IOException + { + GithubTicketResponse ticketResponse = new GithubTicketResponse(); + ticketResponse.title = "title of ticket"; + ticketResponse.body = "description"; + ticketResponse.number = 5; + ticketResponse.labels = Arrays.stream(new String[]{"bug", "unifiedticketing"}) + .map(l -> { + GithubTicketResponse.Label label = new GithubTicketResponse.Label(); + label.name = l; + return label; + }) + .collect(Collectors.toSet()); + GithubTicketResponse.Assignee assignee = new GithubTicketResponse.Assignee(); + assignee.id = 234; + assignee.login = "username"; + ticketResponse.assignees = new HashSet<>(Collections.singleton(assignee)); + + GithubTicket expected = GithubTicket.fromTicketResponse(instance.getParent(), ticketResponse); + String responseJson = new ObjectMapper().writeValueAsString(ticketResponse); + + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create(responseJson.getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + GithubTicket actual = instance.close().save(); + assertEquals(expected, actual); + assertTrue(expected.deepEquals(actual)); + } + + @Test + public void testSaveRequestJson() throws IOException + { + doReturn( + responseBuilder + .code(200) + .body(ResponseBody.create("{}".getBytes(), MediaType.get("application/json"))) + .build()) + .when(call).execute(); + + instance.setTitle("title of ticket") + .setDescription("description") + .setLabels(new HashSet<>(Arrays.asList("bug", "unifiedticketing"))) + .addAssignee("someUsername") + .save(); + + Buffer buffer = new Buffer(); + ObjectMapper mapper = new ObjectMapper(); + requestCaptor.getValue().body().writeTo(buffer); + String expectedJson = "{\"assignees\":[\"someUsername\"],\"body\":\"description\"," + + "\"title\":\"title of ticket\",\"labels\":[\"bug\",\"unifiedticketing\"]}"; + assertEquals(mapper.readTree(expectedJson), mapper.readTree(buffer.readUtf8())); + } +}