Commits (19)
# collected from https://github.com/github/gitignore/tree/master/Global
# ignore build output of docs
/site/
# ignore certain package pathes, dedicated for local playaround classes not to be committed
src/main/java/test/
......
......@@ -23,6 +23,8 @@ mkdocs:compile:
changes:
- docs/**/*
- mkdocs.yml
- CHANGELOG.md
- LICENSE.md
artifacts:
expire_in: 3h
paths:
......@@ -41,6 +43,8 @@ mkdocs:bundle:
changes:
- docs/**/*
- mkdocs.yml
- CHANGELOG.md
- LICENSE.md
artifacts:
expire_in: 3 days
paths:
......@@ -63,3 +67,5 @@ mkdocs:deploy:
changes:
- docs/**/*
- mkdocs.yml
- CHANGELOG.md
- LICENSE.md
......@@ -2,6 +2,14 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [0.2.0](https://transfer.hft-stuttgart.de/gitlab/9Lukas5/unified-ticketing/-/compare/0.1.1...0.2.0) (2020-12-21)
### Features
* **core/filter:** add field and getter for last received item count ([468262f](https://transfer.hft-stuttgart.de/gitlab/unified-ticketing/-/commit/468262f0d033572d0bcff8ddb82b46941fb14751))
* **systems:** add implementation for GitHub ([25207b2](https://transfer.hft-stuttgart.de/gitlab/unified-ticketing/-/commit/25207b2458c14cc4ff7863c9b251b66d6b708967))
### [0.1.1](https://transfer.hft-stuttgart.de/gitlab/9Lukas5/unified-ticketing/-/compare/0.1.0...0.1.1) (2020-12-10)
......
# GitHub-Ticketsystem
This documents GitHub specific things for the integration to this library.
For the global parts, see in the [core sections Ticketsystem site][core-ts].
## create new instance
A `GithubTicketSystem` object is always referencing a specific GitHub project.
Mandatory information to instantiate it, are the GitHub instances base url, the project owner and project.
For public accessible actions, it can be used anonymously.
Other operations have to be authenticated using an username and a personal access token.
For all calls the `accept` header will be set to `application/vnd.github.v3+json`.
If not configured otherwise, connections will use `https`.
### by URI
```java
// recommended option
GithubTicketSystem ts = (GithubTicketSystem) TicketSystem.fromUri("<uri>");
// second possible declaration
TicketSystem<
GithubTicket,
GithubTicketSystem,
GithubTicketBuilder,
GithubFilter
> gl = TicketSystem.fromUri("<uri>");
```
The first declaration option uses the child-class from the GitHub implementation directly.
The second option uses the parent-class instead,
defining the different implementations with Generics.
Use the first option, if you're sure you want to use GitHub no matter what.
This way you can see GitHub specific methods,
whereas the second option keeps you limited on the global defined ones.
If you may switch ticket systems in the future,
the second option forces you to not use system specific stuff, making a transition easier.
The URI to pass has to match this format:
```
unifiedticketing:github:[http://|https://]<base url>::<owner>:<repo>[:<username>:<apikey>]
```
- protocol for http/https is optional. If not given, https is used
- base url can contain also non-standard port hosted github instances
- username and api key if authentication needed, otherwise anonymous access is used
??? example "URI examples"
- non-https anonymous, owner me, project hello-world, public gitlab
`unifiedticketing:github:http://api.github.com::me:hello-world`
- implicit https authenticated, owner me, project hello-world, public gitlab
`unifiedticketing:github:api.github.com::me:hello-world:me:1234896102765abcddbda324bb3a3`
- explicit https authenticated, owner me, project hello-world, fictional github, non-standard port
`unifiedticketing:github:https://api.github.example.org:8080::me:hello-world:me:1234896102765abcddbda324bb3a3`
### by builder
For instantiation from the builder,
mostly the same like building by URI applies.
!!! warning
Passing the base url ensure the protocol (http or https) is removed
??? example "builder examples"
- non-https anonymous, owner me, repo hello-world, public github
```java
GithubTicketSystem ts = TicketSystem.fromBuilder()
.github()
.withBaseUrl("api.github.com")
.withHttp()
.withOwner("me")
.withRepo("hello-world")
.build();
```
- implicit https authenticated, owner me, repo hello-world, public github
```java
GithubTicketSystem ts = TicketSystem.fromBuilder()
.github()
.withBaseUrl("api.github.com")
.withOwner("me")
.withRepo("hello-world")
.withAuthentication("me", "1234896102765abcddbda324bb3a3")
.build();
```
- explicit https authenticated, owner me, repo hello-world, fictional github, non-standard port
```java
GithubTicketSystem ts = TicketSystem.fromBuilder()
.github()
.withBaseUrl("api.github.example.org:8080")
.withHttps()
.withOwner("me")
.withRepo("hello-world")
.withAuthentication("me", "1234896102765abcddbda324bb3a3")
.build();
```
## list tickets
!!! warning "default pagination"
GitHub has a default pagination on _30_ elements per page.
Highest possible value is _100_ elements. [[Link]][api-docs-pagination]
!!! info "assignees"
GitHub implementation supports the fields `id` and `username`.
If ticket has no assignees, assignee field stays `null`.
From the filters shown on the global `TicketSystem` docs,
the ones for title, description filter and ticket id's are not natively supported by the GitHub API.
If such a filter is used,
this get's done locally after receiving the response from GitHub,
before handing it to the user.
## get single ticket
As the GitHub API doesn't support a ticket-id filter on the list endpoint,
the method for getting a single ticket by it's id uses the single ticket endpoint.
If you use the Filter class with a single ticket id as only filter,
you end up fetching all tickets of that repo, filtering out all non matching ones locally.
## create new ticket
For a new GitHub ticket a title is mandatory.
Everything else is optional.
!!! example "minimal example"
```java
GithubTicket = ts.createTicket()
.title("my new ticket")
.create();
```
Setting an assignee, has to be by user login name.
[core-ts]: ../../core/ticket-system.md
[api-docs-issues-list]: https://docs.github.com/en/free-pro-team@latest/rest/reference/issues#list-repository-issues
[api-docs-pagination]: https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#pagination
# GitHub-Ticket
!!! todo
write usage api docs
......@@ -8,6 +8,10 @@ This is the full UML-diagram for the Unified-Ticketing library.
```plantuml
' ==========
' Core
' ==========
abstract Filter {
__ generics definition __
T extends Ticket
......@@ -235,6 +239,10 @@ Ticket o-- TicketAssignee
TicketSystem --> TicketBuilder: "createTicket()"
TicketBuilder --> Ticket: "create()"
' ==========
' Exceptions
' ==========
class AssertionException {
+ constructor()
+ constructor(msg: String)
......@@ -291,6 +299,132 @@ HttpResponseException -[hidden]down- SerializationException
SerializationException -[hidden]down- UnifiedticketingException
UnifiedticketingException -[hidden]down- UnsupportedFunctionException
' ==========
' GitHub
' ==========
class GithubFilter {
- {static} logger: Logger
__ instance definition __
# parent: GithubTicketSystem
# getHttpClient(): OkHttpClient
+ get(): List<GithubTicket>
}
class GithubTicket {
- {static} logger: Logger
# {static} fromTicketResponse(\n\tparent: GithubTicketSystem,\n\tresponse: GithubTicketResponse\n): GithubTicket
__ instance definition __
# constructor(parent: GithubTicketSystem)
+ addAssignee(userId: String): GithubTicket
+ removeAssignee(userId: String): GithubTicket
+ save(): GithubTicket
+ deepEquals(o: Object): boolean
}
class GithubTicketAssignee {
# constructor(id: int, username: String)
}
class GithubTicketBuilder {
- {static} logger: Logger
__ instant definition __
# constructor(parent: GithubTicketSystem)
# getHttpClient(): OkHttpClient
+ create(): GitlabTicket
}
class GithubTicketResponse {
+ assignees: Set<Assignee>
+ body: String
+ labels: Set<Label>
+ number: int
+ state: String
+ title: String
# constructor()
}
class GithubTicketResponse.Assignee {
+ number: int
+ login: String
}
class GithubTicketResponse.Label {
+ name: String
}
class GithubTicketSystem {
- {static} logger: Logger
+ {static} fromUri(uri: String): GithubTicketSystem
__ instance definition __
# constructor()
+ createTicket(): GithubTicketBuilder
+ find(): GithubFilter
+ getTicketById(id: String): GithubTicket
+ hasAssigneeSupport(): boolean
+ hasDefaultPagination(): boolean
+ hasLabelSupport(): boolean
+ hasPaginationSupport(): boolean
+ hasReturnNullOnErrorSupport(): boolean
}
class GithubTicketSystemBuilder {
- {static} logger: Logger
__ instance definition __
# acceptHeader: String
# apiKey: String
# https: boolean
# owner: String
# repo: String
# username: String
+ constructor()
+ withAuthentication(username: String, apiKey: String): GithubTicketSystemBuilder
+ withHttp(): GithubTicketSystemBuilder
+ withHttps(): GithubTicketSystemBuilder
+ withOwner(owner: String): GithubTicketSystemBuilder
+ withRepo(repo: String): GithubTicketSystemBuilder
+ build(): GithubTicketSystem
}
' package connections
GithubFilter o-- GithubTicketSystem: "parent"
GithubTicketResponse o- GithubTicketResponse.Assignee: "inner class"
GithubTicketResponse o-down- GithubTicketResponse.Label: "inner class"
GithubTicketSystemBuilder --> GithubTicketSystem: "build()"
GithubTicketSystem --> GithubTicketBuilder: "createTicket()"
GithubTicketBuilder --> GithubTicket: "create()"
GithubTicketSystem *-- GithubTicketResponse
' core package connections
GithubFilter --|> Filter
GithubTicket --|> Ticket
GithubTicketAssignee --|> TicketAssignee
GithubTicketBuilder --|> TicketBuilder
GithubTicketSystem --|> TicketSystem
GithubTicketSystemBuilder --|> TicketSystemBuilder
RegisteredSystems --> GithubTicketSystemBuilder: "github()"
' ==========
' GitLab
' ==========
class GitlabFilter {
- {static} logger: Logger
......@@ -346,7 +480,7 @@ class GitlabTicketResponse {
# constructor()
}
class Assignee {
class GitlabTicketResponse.Assignee {
+ id: int
+ name: String
+ username: String
......@@ -392,7 +526,7 @@ class GitlabTicketSystemBuilder {
' package connections
GitlabFilter o-- GitlabTicketSystem: "parent"
GitlabTicketResponse o- Assignee: "inner class"
GitlabTicketResponse o- GitlabTicketResponse.Assignee: "inner class"
GitlabTicketSystemBuilder --> GitlabTicketSystem: "build()"
GitlabTicketSystem --> GitlabTicketBuilder: "createTicket()"
GitlabTicketBuilder --> GitlabTicket: "create()"
......
......@@ -251,41 +251,37 @@ You have to surround the PlantUML code with code fences,
marked as `plantuml` as language identifier.
Then they will be rendered into an svg on pages creation.
!!! bug
I tried to put this into an collapsed example admonition,
but the PlantUML diagram get's always placed outside the admonition.
Same applies for a tabbed environment.
Example:
__code:__
``````
```plantuml
queue "write\nmarkdown" as write
queue "commit/push/\nmerge changes" as update
queue "pipeline\ntriggered" as cicd
queue "MkDocs\nMaterial" as MkDocsMat
queue "transpiled HTML\nonline" as deployed
write -right-> update
update -right-> cicd
cicd -right-> MkDocsMat
MkDocsMat -right-> deployed
```
``````
__result:__
```plantuml
queue "write\nmarkdown" as write
queue "commit/push/\nmerge changes" as update
queue "pipeline\ntriggered" as cicd
queue "MkDocs\nMaterial" as MkDocsMat
queue "transpiled HTML\nonline" as deployed
write -right-> update
update -right-> cicd
cicd -right-> MkDocsMat
MkDocsMat -right-> deployed
```
Example:
=== "code"
``````
```plantuml
queue "write\nmarkdown" as write
queue "commit/push/\nmerge changes" as update
queue "pipeline\ntriggered" as cicd
queue "MkDocs\nMaterial" as MkDocsMat
queue "transpiled HTML\nonline" as deployed
write -right-> update
update -right-> cicd
cicd -right-> MkDocsMat
MkDocsMat -right-> deployed
```
``````
=== "result"
```plantuml
queue "write\nmarkdown" as write
queue "commit/push/\nmerge changes" as update
queue "pipeline\ntriggered" as cicd
queue "MkDocs\nMaterial" as MkDocsMat
queue "transpiled HTML\nonline" as deployed
write -right-> update
update -right-> cicd
cicd -right-> MkDocsMat
MkDocsMat -right-> deployed
```
## Schedule a release
......
......@@ -64,7 +64,7 @@ He is the current maintainer for this project and the appropriate contact for qu
[badge-conventional-commits]: https://img.shields.io/badge/Conventional%20Commits-1.0.0-orange.svg
[badge-gplv3]: https://img.shields.io/badge/License-GPLv3-blue.svg
[badge-latest-pipe]: https://img.shields.io/badge/dynamic/json?color=lightgrey&label=latest%20dev%20pipeline&query=%24%5B0%5D.status&url=https%3A%2F%2Ftransfer.hft-stuttgart.de%2Fgitlab%2Fapi%2Fv4%2F%2Fprojects%2F154%2Fpipelines%3Fper_page%3D1
[badge-latest-tag]: https://img.shields.io/badge/dynamic/json?color=green&label=maven&query=%24%5B0%5D.name&url=https%3A%2F%2Ftransfer.hft-stuttgart.de%2Fgitlab%2Fapi%2Fv4%2Fprojects%2F154%2Frepository%2Ftags%3Fper_page%3D1
[badge-latest-tag]: https://img.shields.io/badge/dynamic/json?color=success&label=maven&query=%24%5B0%5D.name&url=https%3A%2F%2Ftransfer.hft-stuttgart.de%2Fgitlab%2Fapi%2Fv4%2Fprojects%2F154%2Frepository%2Ftags%3Fper_page%3D1
[badge-master-pipe]: https://transfer.hft-stuttgart.de/gitlab/9Lukas5/unified-ticketing/badges/master/pipeline.svg
[convcomm]: https://conventionalcommits.org
[gplv3]: unified-ticketing/license-softlink.md
......
......@@ -24,6 +24,9 @@ nav:
- Ticketsystem: 'api/core/ticket-system.md'
- Exceptions: 'api/exceptions.md'
- Systems:
- GitHub:
- Ticket: 'api/systems/github/ticket.md'
- Ticketsystem: 'api/systems/github/ticket-system.md'
- GitLab:
- Ticket: 'api/systems/gitlab/ticket.md'
- Ticketsystem: 'api/systems/gitlab/ticket-system.md'
......
......@@ -21,6 +21,7 @@ public abstract class Filter<T extends Ticket, F extends Filter<?,?>>
{
private static Logger logger = Logging.getLogger(Filter.class.getName());
protected int lastReceivedItemCount;
protected Map<String, Object> setFilters;// = new HashMap<>();
protected enum FilterNames
......@@ -45,6 +46,22 @@ public abstract class Filter<T extends Ticket, F extends Filter<?,?>>
this.getClass().getSimpleName()
));
setFilters = new HashMap<>();
lastReceivedItemCount = -1;
}
/**
* Counter how many items received on last {@link #get()} call.
*
* As local filtering can lower the items delivered from {@link #get()},
* you can't check that no more pages are available comparing the delivered item
* count with your set page size.<br>
*
* @return item count received from external Ticketsystem on last call of {@link #get()}
*/
public int getLastReceivedItemCount()
{
return lastReceivedItemCount;
}
/**
......
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
*/
......
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));
......
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);
}
lastReceivedItemCount = tr.size();
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;
}
}
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);
}
}
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);
}
}
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);
}
}
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;
}
}
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;
}
}
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
);
}
}
......@@ -189,6 +189,8 @@ public class GitlabFilter extends Filter<GitlabTicket, GitlabFilter>
else throw new DeserializationException(e);
}
lastReceivedItemCount = tr.size();
logger.log(Level.FINER, "starting query post filter");
Stream<GitlabTicketResponse> ticketStream = tr.stream();
......