From 211d41748cb2c39475778441e4fcb018192d2d35 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Sun, 30 Apr 2017 19:55:02 +0200 Subject: [PATCH 01/22] Initial attempt at setting up a database with PostgreSQL and jOOQ --- .gitignore | 4 + logging.properties | 2 - pom.xml | 328 ++++++++++++++---- .../java/previewcode/backend/APIModule.java | 78 +++++ .../previewcode/backend/DTO/Ordering.java | 25 -- .../backend/DTO/OrderingGroup.java | 40 +++ .../backend/DTO/OrderingGroupWithID.java | 23 ++ .../java/previewcode/backend/DTO/PRbody.java | 36 +- .../backend/DTO/PullRequestLinks.java | 13 + .../backend/DTO/TitleDescription.java | 19 +- .../java/previewcode/backend/MainModule.java | 151 +++++--- .../AbstractExceptionMapper.java | 59 +++- .../GitHubApiExceptionMapper.java | 5 + .../IllegalArgumentExceptionMapper.java | 5 + .../exceptionmapper/RootExceptionMapper.java | 9 + .../backend/api/v1/AssigneesAPI.java | 2 +- .../backend/api/v1/CommentsAPI.java | 2 +- .../backend/api/v1/PullRequestAPI.java | 15 +- .../previewcode/backend/api/v1/StatusAPI.java | 2 +- .../backend/api/v1/TrackerAPI.java | 2 +- .../backend/api/v1/WebhookAPI.java | 3 +- .../backend/api/v2/OrderingAPI.java | 47 +++ .../previewcode/backend/api/v2/TestAPI.java | 35 ++ .../backend/database/DatabaseException.java | 15 + .../backend/database/DatabaseID.java | 38 ++ .../backend/database/DatabaseInterpreter.java | 74 ++++ .../previewcode/backend/database/GroupID.java | 7 + .../previewcode/backend/database/HunkID.java | 41 +++ .../backend/database/PullRequestGroup.java | 68 ++++ .../backend/database/PullRequestID.java | 7 + .../backend/services/DatabaseService.java | 46 +++ .../backend/services/FirebaseService.java | 5 +- .../backend/services/IDatabaseService.java | 16 + .../backend/services/actiondsl/ActionDSL.java | 273 +++++++++++++++ .../services/actiondsl/ActionMain.java | 83 +++++ .../services/actiondsl/Interpreter.java | 194 +++++++++++ .../services/actions/DatabaseActions.java | 116 +++++++ .../services/actions/GitHubActions.java | 52 +++ .../backend/services/actions/LogActions.java | 17 + .../db-migration/V1__initialise_database.sql | 46 +++ src/main/resources/log4j2-test.yaml | 14 + src/main/resources/log4j2.yaml | 7 + .../backend/api/v2/EndPointTest.java | 82 +++++ .../database/DatabaseInterpreterTest.java | 29 ++ .../DatabaseInterpreter_GroupTest.java | 122 +++++++ .../DatabaseInterpreter_HunksTest.java | 18 + .../DatabaseInterpreter_PullRequestTest.java | 87 +++++ .../backend/database/SchemaTest.java | 52 +++ .../backend/services/DatabaseServiceTest.java | 152 ++++++++ .../helpers/AnnotatedClassInstantiator.java | 88 +++++ .../backend/test/helpers/ApiEndPointTest.java | 35 ++ .../test/helpers/DatabaseTestExtension.java | 81 +++++ .../backend/test/helpers/DatabaseTests.java | 32 ++ .../test/helpers/GuiceResteasyExtension.java | 85 +++++ 54 files changed, 2699 insertions(+), 188 deletions(-) delete mode 100644 logging.properties create mode 100644 src/main/java/previewcode/backend/APIModule.java delete mode 100644 src/main/java/previewcode/backend/DTO/Ordering.java create mode 100644 src/main/java/previewcode/backend/DTO/OrderingGroup.java create mode 100644 src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java create mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/RootExceptionMapper.java create mode 100644 src/main/java/previewcode/backend/api/v2/OrderingAPI.java create mode 100644 src/main/java/previewcode/backend/api/v2/TestAPI.java create mode 100644 src/main/java/previewcode/backend/database/DatabaseException.java create mode 100644 src/main/java/previewcode/backend/database/DatabaseID.java create mode 100644 src/main/java/previewcode/backend/database/DatabaseInterpreter.java create mode 100644 src/main/java/previewcode/backend/database/GroupID.java create mode 100644 src/main/java/previewcode/backend/database/HunkID.java create mode 100644 src/main/java/previewcode/backend/database/PullRequestGroup.java create mode 100644 src/main/java/previewcode/backend/database/PullRequestID.java create mode 100644 src/main/java/previewcode/backend/services/DatabaseService.java create mode 100644 src/main/java/previewcode/backend/services/IDatabaseService.java create mode 100644 src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java create mode 100644 src/main/java/previewcode/backend/services/actiondsl/ActionMain.java create mode 100644 src/main/java/previewcode/backend/services/actiondsl/Interpreter.java create mode 100644 src/main/java/previewcode/backend/services/actions/DatabaseActions.java create mode 100644 src/main/java/previewcode/backend/services/actions/GitHubActions.java create mode 100644 src/main/java/previewcode/backend/services/actions/LogActions.java create mode 100644 src/main/resources/db-migration/V1__initialise_database.sql create mode 100644 src/test/java/previewcode/backend/api/v2/EndPointTest.java create mode 100644 src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java create mode 100644 src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java create mode 100644 src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java create mode 100644 src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java create mode 100644 src/test/java/previewcode/backend/database/SchemaTest.java create mode 100644 src/test/java/previewcode/backend/services/DatabaseServiceTest.java create mode 100644 src/test/java/previewcode/backend/test/helpers/AnnotatedClassInstantiator.java create mode 100644 src/test/java/previewcode/backend/test/helpers/ApiEndPointTest.java create mode 100644 src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java create mode 100644 src/test/java/previewcode/backend/test/helpers/DatabaseTests.java create mode 100644 src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java diff --git a/.gitignore b/.gitignore index 6759c8e..de657f3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ hs_err_pid* ### certificates ### secrets/ + +### Generated sources ### +src/main/java/previewcode/backend/database/model +*database.mv.db diff --git a/logging.properties b/logging.properties deleted file mode 100644 index cba405d..0000000 --- a/logging.properties +++ /dev/null @@ -1,2 +0,0 @@ -# Set the default logging level for all loggers to WARNING -.level = WARNING diff --git a/pom.xml b/pom.xml index 8937f15..7c22e26 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,19 @@ 3.1.2.Final 9.4.4.v20170414 2.8.8 + 42.0.0 + 3.9.2 + + 5.0.0-M4 + ${junit.version}.0-M4 + 1.0.0-M4 + + 4.12 + + jdbc:postgresql://localhost:5432/preview_code + admin + password + preview_code @@ -23,71 +36,64 @@ - - + - com.google.firebase - firebase-server-sdk - [3.0.0,) + javax.servlet + javax.servlet-api + ${javax.servlet.version} - - com.squareup.okhttp3 - okhttp - 3.7.0 + org.jboss.resteasy + resteasy-jaxrs + ${resteasy.version} - - com.auth0 - java-jwt - 3.1.0 + org.jboss.resteasy + resteasy-guice + ${resteasy.version} - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} - - - com.fasterxml.jackson.core - jackson-core - ${jackson.version} + org.jboss.resteasy + resteasy-jackson2-provider + ${resteasy.version} - - com.fasterxml.jackson.jaxrs - jackson-jaxrs-json-provider - ${jackson.version} + org.eclipse.jetty + jetty-servlet + ${jetty.version} - - org.jboss.resteasy - resteasy-jaxrs - ${resteasy.version} + org.eclipse.jetty + jetty-servlets + ${jetty.version} + + - org.jboss.resteasy - resteasy-guice - ${resteasy.version} + com.fasterxml.jackson.jaxrs + jackson-jaxrs-json-provider + ${jackson.version} - org.jboss.resteasy - resteasy-jackson2-provider - ${resteasy.version} + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} - javax.servlet - javax.servlet-api - ${javax.servlet.version} + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + com.google.inject.extensions @@ -96,37 +102,70 @@ - com.google.inject.extensions - guice-persist + com.google.inject + guice ${guice.version} + + - com.google.inject - guice - ${guice.version} + org.jooq + jooq + ${jooq.version} - - com.google.guava - guava - 18.0 + org.jooq + jooq-codegen + ${jooq.version} + - org.reflections - reflections - 0.9.8 + org.postgresql + postgresql + 42.0.0 - + + + com.jolbox + bonecp + 0.8.0.RELEASE + + + + + org.kohsuke github-api 1.76 + + + com.google.firebase + firebase-server-sdk + [3.0.0,) + + + + + com.squareup.okhttp3 + okhttp + 3.7.0 + + + + + com.auth0 + java-jwt + 3.1.0 + + + org.slf4j @@ -144,43 +183,82 @@ 2.8.2 + com.fasterxml.jackson.dataformat jackson-dataformat-yaml ${jackson.version} + + + + com.google.guava + guava + 18.0 + + + + org.reflections + reflections + 0.9.8 + + + + io.atlassian.fugue + fugue + 4.5.0 + + + + io.vavr + vavr + 0.9.0 + + + + io.vavr + vavr-jackson + 0.9.0 + + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + junit junit - 4.12 + ${junit.version} + test - org.mockito - mockito-core - 2.7.22 + org.assertj + assertj-core + 3.7.0 + test org.jboss.resteasy resteasy-client ${resteasy.version} + test - - org.eclipse.jetty - jetty-servlet - ${jetty.version} - - - - org.eclipse.jetty - jetty-servlets - ${jetty.version} - + + + + + + @@ -188,19 +266,18 @@ ${project.build.directory}/${project.build.finalName}/WEB-INF/classes src/main/java - - + src/test/java src/main/resources - - - - - + + + src/test/resources + + @@ -213,6 +290,111 @@ + + + org.flywaydb + flyway-maven-plugin + 4.2.0 + + + + generate-sources + + migrate + + + + + + ${db.url} + ${db.username} + ${db.password} + + filesystem:src/main/resources/db-migration + + + + + + + + org.jooq + jooq-codegen-maven + ${jooq.version} + + + + + generate + + + + + + + org.postgresql + postgresql + ${postgres.version} + + + + + + ${db.url} + ${db.username} + ${db.password} + + + + + + .* + ${db.schema} + + + + true + true + + + + previewcode.backend.database.model + src/main/java + + + + + + + maven-surefire-plugin + 2.19.1 + + + **/Test*.java + **/*Test.java + **/*Tests.java + **/*TestCase.java + + + + + org.junit.platform + junit-platform-surefire-provider + ${junit.platform.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + + + org.junit.vintage + junit-vintage-engine + ${junit.vintage.version} + + + + org.apache.maven.plugins diff --git a/src/main/java/previewcode/backend/APIModule.java b/src/main/java/previewcode/backend/APIModule.java new file mode 100644 index 0000000..96f6ba3 --- /dev/null +++ b/src/main/java/previewcode/backend/APIModule.java @@ -0,0 +1,78 @@ +package previewcode.backend; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.google.inject.servlet.ServletModule; +import io.atlassian.fugue.Unit; +import org.jboss.resteasy.plugins.guice.ext.JaxrsModule; +import previewcode.backend.api.exceptionmapper.GitHubApiExceptionMapper; +import previewcode.backend.api.exceptionmapper.IllegalArgumentExceptionMapper; +import previewcode.backend.api.exceptionmapper.RootExceptionMapper; +import previewcode.backend.api.v2.OrderingAPI; +import previewcode.backend.api.v2.TestAPI; + +import javax.ws.rs.ext.ContextResolver; +import javax.ws.rs.ext.Provider; +import java.io.IOException; + + +public class APIModule extends ServletModule { + + @SuppressWarnings("PointlessBinding") + @Override + public void configureServlets() { + this.install(new JaxrsModule()); + + // API Endpoints + // v2 + this.bind(TestAPI.class); + this.bind(OrderingAPI.class); + + // Exception mappers + this.bind(RootExceptionMapper.class); + this.bind(IllegalArgumentExceptionMapper.class); + this.bind(GitHubApiExceptionMapper.class); + this.bind(JacksonObjectMapperProvider.class); + } + + + /** + * Plumbing necessary to convert Unit values to an empty response body when sent back over the network + */ + @Provider + static class JacksonObjectMapperProvider implements ContextResolver { + + final ObjectMapper defaultObjectMapper; + + public JacksonObjectMapperProvider() { + defaultObjectMapper = createDefaultMapper(); + } + + @Override + public ObjectMapper getContext(Class type) { + return defaultObjectMapper; + } + + private static ObjectMapper createDefaultMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new UnitSerializerModule()); + return mapper; + } + + } + + static class UnitSerializerModule extends SimpleModule { + public UnitSerializerModule() { + super(); + this.addSerializer(Unit.class, new JsonSerializer() { + @Override + public void serialize(Unit value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.close(); + } + }); + } + } +} diff --git a/src/main/java/previewcode/backend/DTO/Ordering.java b/src/main/java/previewcode/backend/DTO/Ordering.java deleted file mode 100644 index db05519..0000000 --- a/src/main/java/previewcode/backend/DTO/Ordering.java +++ /dev/null @@ -1,25 +0,0 @@ -package previewcode.backend.DTO; - -import java.util.List; -/** - * The ordering of the pull request - * - */ -public class Ordering { - - /** - * The list of diffs in the pul request - */ - public List diff; - - /** - * The id of the group - */ - public String id; - - /** - * The body of the group - */ - public TitleDescription info; - -} diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroup.java b/src/main/java/previewcode/backend/DTO/OrderingGroup.java new file mode 100644 index 0000000..676c1ec --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/OrderingGroup.java @@ -0,0 +1,40 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +/** + * The ordering of the pull request + * + */ +@JsonIgnoreProperties(ignoreUnknown=true) +public class OrderingGroup { + + /** + * The list of diffs in the pul request + */ + @JsonProperty("diff") + public final List diff; + + /** + * The body of the group + */ + @JsonProperty("info") + public final TitleDescription info; + + + @JsonCreator + public OrderingGroup( + @JsonProperty("diff") List diff, + @JsonProperty("info") TitleDescription info) { + this.diff = diff; + this.info = info; + } + + public OrderingGroup(String title, String description, List hunks) { + this.diff = hunks; + this.info = new TitleDescription(title, description); + } +} diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java new file mode 100644 index 0000000..a2d13f2 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java @@ -0,0 +1,23 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import previewcode.backend.database.PullRequestGroup; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown=true) +public class OrderingGroupWithID extends OrderingGroup { + + /** + * The id of the group + */ + @JsonProperty("id") + public final String id; + + + public OrderingGroupWithID(PullRequestGroup dbGroup, List hunkIds) { + super(dbGroup.title, dbGroup.description, hunkIds); + this.id = dbGroup.id.id.toString(); + } +} diff --git a/src/main/java/previewcode/backend/DTO/PRbody.java b/src/main/java/previewcode/backend/DTO/PRbody.java index 962634d..8664ccc 100644 --- a/src/main/java/previewcode/backend/DTO/PRbody.java +++ b/src/main/java/previewcode/backend/DTO/PRbody.java @@ -1,27 +1,53 @@ package previewcode.backend.DTO; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + import java.util.List; /** * The data for the newly made pull request */ -public class PRbody extends TitleDescription{ +@JsonIgnoreProperties(ignoreUnknown=true) +public class PRbody extends TitleDescription { /** * The head branch of the newly made pull request */ - public String head; + @JsonProperty("head") + public final String head; + /** * The base branch of the newly made pull request */ - public String base; + @JsonProperty("base") + public final String base; + /** * The ordering of the changes of the pull request */ - public List ordering; + @JsonProperty("ordering") + public final List ordering; + /** * If the description should include metadata. */ - public boolean metadata; + @JsonProperty("metadata") + public final Boolean metadata; + @JsonCreator + public PRbody( + @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("head") String head, + @JsonProperty("base") String base, + @JsonProperty("ordering") List ordering, + @JsonProperty("metadata") Boolean metadata) { + super(title, description); + this.head = head; + this.base = base; + this.ordering = ordering; + this.metadata = metadata; + } } diff --git a/src/main/java/previewcode/backend/DTO/PullRequestLinks.java b/src/main/java/previewcode/backend/DTO/PullRequestLinks.java index 4708e14..5abdad6 100644 --- a/src/main/java/previewcode/backend/DTO/PullRequestLinks.java +++ b/src/main/java/previewcode/backend/DTO/PullRequestLinks.java @@ -34,6 +34,19 @@ public PullRequestLinks( this.reviewComment = reviewComment.href; this.statuses = statuses.href; } + + public PullRequestLinks(String self, String html, String issue, + String comments, String statuses, String reviewComments, + String reviewComment, String commits) { + this.self = self; + this.html = html; + this.issue = issue; + this.comments = comments; + this.commits = commits; + this.reviewComments = reviewComments; + this.reviewComment = reviewComment; + this.statuses = statuses; + } } class HRef { diff --git a/src/main/java/previewcode/backend/DTO/TitleDescription.java b/src/main/java/previewcode/backend/DTO/TitleDescription.java index c50c7a1..fa8b394 100644 --- a/src/main/java/previewcode/backend/DTO/TitleDescription.java +++ b/src/main/java/previewcode/backend/DTO/TitleDescription.java @@ -1,18 +1,33 @@ package previewcode.backend.DTO; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Object that has a title and body * */ +@JsonIgnoreProperties(ignoreUnknown=true) public class TitleDescription { /** * The title of the object */ - public String title; + @JsonProperty("title") + public final String title; /** * The description of the object */ - public String description; + @JsonProperty("description") + public final String description; + + @JsonCreator + public TitleDescription( + @JsonProperty("title") String title, + @JsonProperty("description") String description) { + this.title = title; + this.description = description; + } } diff --git a/src/main/java/previewcode/backend/MainModule.java b/src/main/java/previewcode/backend/MainModule.java index bd1d6fa..5a72a51 100644 --- a/src/main/java/previewcode/backend/MainModule.java +++ b/src/main/java/previewcode/backend/MainModule.java @@ -8,25 +8,24 @@ import com.google.inject.Provides; import com.google.inject.name.Named; import com.google.inject.servlet.RequestScoped; -import com.google.inject.servlet.ServletModule; -import org.jboss.resteasy.plugins.guice.ext.JaxrsModule; +import com.jolbox.bonecp.BoneCPDataSource; import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider; import org.jboss.resteasy.util.Base64; +import org.jooq.DSLContext; +import org.jooq.SQLDialect; +import org.jooq.conf.Settings; +import org.jooq.impl.DSL; import org.kohsuke.github.GitHub; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import previewcode.backend.api.exceptionmapper.GitHubApiExceptionMapper; -import previewcode.backend.api.exceptionmapper.IllegalArgumentExceptionMapper; import previewcode.backend.api.filter.GitHubAccessTokenFilter; -import previewcode.backend.api.v1.AssigneesAPI; -import previewcode.backend.api.v1.CommentsAPI; -import previewcode.backend.api.v1.PullRequestAPI; -import previewcode.backend.api.v1.StatusAPI; -import previewcode.backend.api.v1.TrackerAPI; -import previewcode.backend.api.v1.WebhookAPI; +import previewcode.backend.api.v1.*; +import previewcode.backend.services.DatabaseService; import previewcode.backend.services.GithubService; +import previewcode.backend.services.IDatabaseService; import javax.crypto.spec.SecretKeySpec; +import javax.sql.DataSource; import javax.ws.rs.NotAuthorizedException; import java.io.File; import java.io.FileInputStream; @@ -41,30 +40,36 @@ * @author PReview-Code * */ -public class MainModule extends ServletModule { +public class MainModule extends APIModule { private static final Logger logger = LoggerFactory.getLogger(MainModule.class); - private static Algorithm RSA_PRIVATE_KEY; - private static SecretKeySpec GITHUB_WEBHOOK_SECRET; - private static String INTEGRATION_ID; + private static final Algorithm RSA_PRIVATE_KEY = initPrivateRSAKey(); + private static final SecretKeySpec GITHUB_WEBHOOK_SECRET = initGitHubWebhookSecret(); + private static final String INTEGRATION_ID = initIntegrationId(); + private static final DataSource DATA_SOURCE = initConnectionPool(); - /** - * The method that configures the servlets - */ + @SuppressWarnings("PointlessBinding") @Override public void configureServlets() { - this.install(new JaxrsModule()); - this.bind(GitHubAccessTokenFilter.class); + super.configureServlets(); + + // v1 this.bind(StatusAPI.class); this.bind(PullRequestAPI.class); this.bind(CommentsAPI.class); this.bind(AssigneesAPI.class); this.bind(TrackerAPI.class); - this.bind(IllegalArgumentExceptionMapper.class); - this.bind(GitHubApiExceptionMapper.class); - this.bind(ResteasyJackson2Provider.class); this.bind(WebhookAPI.class); + this.bind(GitHubAccessTokenFilter.class); + this.bind(ResteasyJackson2Provider.class); + + this.bind(IDatabaseService.class).to(DatabaseService.class); + + initializeFireBase(); + } + + private void initializeFireBase() { try { logger.info("Loading Firebase auth..."); FileInputStream file = new FileInputStream(System.getenv("FIREBASE_AUTH")); @@ -82,32 +87,64 @@ public void configureServlets() { } } + @Provides + private static DSLContext provideJooqDSL() { + Settings settings = new Settings().withExecuteLogging(true); + return DSL.using(DATA_SOURCE, SQLDialect.POSTGRES, settings); + } + + @Provides + public DataSource provideDataSource() { + return DATA_SOURCE; + } + + private static DataSource initConnectionPool() { + try { + logger.info("Instantiating connection pool..."); + BoneCPDataSource result = new BoneCPDataSource(); + result.setDriverClass("org.postgresql.Driver"); + result.setJdbcUrl("jdbc:postgresql:library"); + result.setUsername("postgres"); + result.setPassword("test"); + result.setDefaultAutoCommit(true); + result.setPartitionCount(4); + result.setMinConnectionsPerPartition(1); + result.setMaxConnectionsPerPartition(10); + return result; + } catch (Exception e) { + logger.error("Unable to create JDBC DataSource: ", e); + } + System.exit(-1); + return null; + } + /** * Provides the signing algorithm to sign JWT keys destined for authenticating * with GitHub Integrations. */ @Provides public Algorithm provideJWTSigningAlgo() { + return RSA_PRIVATE_KEY; + } + + private static Algorithm initPrivateRSAKey() { try { - if (RSA_PRIVATE_KEY == null) { - logger.info("Loading GitHub Integration RSA key..."); - File file = new File(System.getenv("INTEGRATION_KEY")); - String key = Files.toString(file, Charsets.UTF_8) - .replace("-----END PRIVATE KEY-----", "") - .replace("-----BEGIN PRIVATE KEY-----", "") - .replaceAll("\n", "").trim(); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.decode(key)); - KeyFactory kf = KeyFactory.getInstance("RSA"); - RSA_PRIVATE_KEY = Algorithm.RSA256((RSAPrivateKey) kf.generatePrivate(keySpec)); - } - } catch (NullPointerException e){ + logger.info("Loading GitHub Integration RSA key..."); + File file = new File(System.getenv("INTEGRATION_KEY")); + String key = Files.toString(file, Charsets.UTF_8) + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replaceAll("\n", "").trim(); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.decode(key)); + KeyFactory kf = KeyFactory.getInstance("RSA"); + return Algorithm.RSA256((RSAPrivateKey) kf.generatePrivate(keySpec)); + } catch (NullPointerException e) { logger.error("Environmental variable for GitHub Integration RSA not set"); - System.exit(-1); } catch (Exception e) { logger.error("Failed to load GitHub Integration RSA key:", e); - System.exit(-1); } - return RSA_PRIVATE_KEY; + System.exit(-1); + return null; } /** @@ -116,40 +153,44 @@ public Algorithm provideJWTSigningAlgo() { @Provides @Named("github.webhook.secret") public SecretKeySpec provideGitHubWebhookSecret() { - if (GITHUB_WEBHOOK_SECRET == null) { - try { - logger.info("Loading GitHub Integration Webhook key..."); - File file = new File(System.getenv("WEBHOOK_SECRET")); - final String secret = Files.toString(file, Charsets.UTF_8).trim(); - GITHUB_WEBHOOK_SECRET = new SecretKeySpec(secret.getBytes(), "HmacSHA1"); - } catch (IOException e) { - logger.error("Failed to load GitHub Integration webhook secret:", e); - System.exit(-1); - } catch (NullPointerException e){ - logger.error("Environmental variable for GitHub Integration webhook secret not set"); - System.exit(-1); - } - } return GITHUB_WEBHOOK_SECRET; } + private static SecretKeySpec initGitHubWebhookSecret() { + try { + logger.info("Loading GitHub Integration Webhook key..."); + File file = new File(System.getenv("WEBHOOK_SECRET")); + final String secret = Files.toString(file, Charsets.UTF_8).trim(); + return new SecretKeySpec(secret.getBytes(), "HmacSHA1"); + } catch (IOException e) { + logger.error("Failed to load GitHub Integration webhook secret:", e); + } catch (NullPointerException e){ + logger.error("Environmental variable for GitHub Integration webhook secret not set"); + } + System.exit(-1); + return null; + } + /** * Method to declare Named key "integration.id" to obtain the current GitHub Integration id. */ @Provides @Named("integration.id") public String provideIntegrationId() { + return INTEGRATION_ID; + } + + private static String initIntegrationId() { try { logger.info("Loading GitHub Integration ID..."); - INTEGRATION_ID = Files.toString(new File(System.getenv("INTEGRATION_ID")), Charsets.UTF_8).trim(); + return Files.toString(new File(System.getenv("INTEGRATION_ID")), Charsets.UTF_8).trim(); } catch (IOException e) { logger.error("Failed to load GitHub Integration ID:", e); - System.exit(-1); } catch (NullPointerException e){ logger.error("Environmental variable for the GitHub Integration ID not set"); - System.exit(-1); } - return INTEGRATION_ID; + System.exit(-1); + return null; } /** diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/AbstractExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/AbstractExceptionMapper.java index 93e2397..6988e55 100644 --- a/src/main/java/previewcode/backend/api/exceptionmapper/AbstractExceptionMapper.java +++ b/src/main/java/previewcode/backend/api/exceptionmapper/AbstractExceptionMapper.java @@ -1,9 +1,9 @@ package previewcode.backend.api.exceptionmapper; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.jaxrs.json.annotation.JSONP; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,6 +11,7 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.ext.ExceptionMapper; +import java.util.Objects; import java.util.UUID; /** @@ -26,18 +27,22 @@ public abstract class AbstractExceptionMapper implements Ex @Override public Response toResponse(final T exception) { - logger.error("Unhandled exception in API call:", exception); + UUID uuid = this.newUUID(exception); + + logger.error("Unhandled exception in API call:"); + logger.error(" UUID: " + uuid); + logger.error(" Exception: ", exception); try { - return createResponse(exception); + return createResponse(uuid, exception); } catch (JsonProcessingException e) { throw new RuntimeException("Could not convert object to JSON", e); } } - protected Response createResponse(final T exception) throws JsonProcessingException { + protected Response createResponse(final UUID uuid, final T exception) throws JsonProcessingException { final ExceptionResponse exceptionResponse = new ExceptionResponse( - this.getUuid(exception).toString(), - exception.getMessage() + uuid.toString(), + this.getExposedMessage(exception) ); return Response.status(this.getStatusCode(exception)) @@ -46,12 +51,21 @@ protected Response createResponse(final T exception) throws JsonProcessingExcept .build(); } - protected UUID getUuid(T exception) { - return UUID.randomUUID(); + /** + * Creates a response string to send back over the network. + * Care should be taken to avoid sending sensitive data back to users. + */ + protected String getExposedMessage(T exception) { + return "An error occurred, try again later or contact an administrator."; } - public abstract Status getStatusCode(final T exception); + protected UUID newUUID(T exception) { + return UUID.randomUUID(); + } + protected Status getStatusCode(final T exception) { + return Status.INTERNAL_SERVER_ERROR; + } } /** @@ -80,9 +94,32 @@ class ExceptionResponse { public final String message; - - public ExceptionResponse(String uuid, String message) { + @JsonCreator + public ExceptionResponse(@JsonProperty("uuid") String uuid, @JsonProperty("message") String message) { + Objects.requireNonNull(uuid, message); this.uuid = uuid; this.message = message; } + + @Override + public String toString() { + return "ExceptionResponse{" + + "message='" + message + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ExceptionResponse that = (ExceptionResponse) o; + + return message.equals(that.message); + } + + @Override + public int hashCode() { + return message.hashCode(); + } } \ No newline at end of file diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java index a051178..d06de49 100644 --- a/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java +++ b/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java @@ -11,4 +11,9 @@ public class GitHubApiExceptionMapper extends public Response.Status getStatusCode(GitHubApiException exception) { return Response.Status.fromStatusCode(exception.statusCode); } + + @Override + protected String getExposedMessage(GitHubApiException exception) { + return "Error while calling GitHub API: " + exception.getMessage(); + } } diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/IllegalArgumentExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/IllegalArgumentExceptionMapper.java index 34829e8..bc4e68c 100644 --- a/src/main/java/previewcode/backend/api/exceptionmapper/IllegalArgumentExceptionMapper.java +++ b/src/main/java/previewcode/backend/api/exceptionmapper/IllegalArgumentExceptionMapper.java @@ -19,4 +19,9 @@ public Response.Status getStatusCode(IllegalArgumentException e) { return BAD_REQUEST; } + + @Override + protected String getExposedMessage(IllegalArgumentException exception) { + return exception.getMessage(); + } } diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/RootExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/RootExceptionMapper.java new file mode 100644 index 0000000..dd5dfa1 --- /dev/null +++ b/src/main/java/previewcode/backend/api/exceptionmapper/RootExceptionMapper.java @@ -0,0 +1,9 @@ +package previewcode.backend.api.exceptionmapper; + +import javax.ws.rs.ext.Provider; + +/** + * Catches all Throwables and lets AbstractExceptionMapper create an default response. + */ +@Provider +public class RootExceptionMapper extends AbstractExceptionMapper { } diff --git a/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java b/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java index 1299855..b97c6e0 100644 --- a/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java +++ b/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java @@ -19,7 +19,7 @@ * API endpoint for approving hunks * */ -@Path("{owner}/{name}/pulls/{number}/approve") +@Path("v1/{owner}/{name}/pulls/{number}/approve") public class AssigneesAPI { @Inject diff --git a/src/main/java/previewcode/backend/api/v1/CommentsAPI.java b/src/main/java/previewcode/backend/api/v1/CommentsAPI.java index 730cf5b..f3f5863 100644 --- a/src/main/java/previewcode/backend/api/v1/CommentsAPI.java +++ b/src/main/java/previewcode/backend/api/v1/CommentsAPI.java @@ -20,7 +20,7 @@ * API endpoint for comments * */ -@Path("{owner}/{name}/pulls/{number}/comments/") +@Path("v1/{owner}/{name}/pulls/{number}/comments") public class CommentsAPI { @Inject diff --git a/src/main/java/previewcode/backend/api/v1/PullRequestAPI.java b/src/main/java/previewcode/backend/api/v1/PullRequestAPI.java index 02d573b..ae737a3 100644 --- a/src/main/java/previewcode/backend/api/v1/PullRequestAPI.java +++ b/src/main/java/previewcode/backend/api/v1/PullRequestAPI.java @@ -1,9 +1,8 @@ package previewcode.backend.api.v1; import com.google.inject.Inject; -import org.jboss.resteasy.annotations.Suspend; import previewcode.backend.DTO.GitHubPullRequest; -import previewcode.backend.DTO.Ordering; +import previewcode.backend.DTO.OrderingGroup; import previewcode.backend.DTO.OrderingStatus; import previewcode.backend.DTO.PRbody; import previewcode.backend.DTO.PrNumber; @@ -12,7 +11,11 @@ import previewcode.backend.services.FirebaseService; import previewcode.backend.services.GithubService; -import javax.ws.rs.*; +import javax.ws.rs.Consumes; +import javax.ws.rs.NotAuthorizedException; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.container.AsyncResponse; import javax.ws.rs.container.Suspended; import javax.ws.rs.core.MediaType; @@ -20,10 +23,9 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.util.List; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -@Path("{owner}/{name}/pulls/") +@Path("v1/{owner}/{name}/pulls") public class PullRequestAPI { @Inject @@ -80,7 +82,7 @@ public void updateOrdering( @Suspended AsyncResponse response, @PathParam("owner") String owner, @PathParam("name") String name, - @PathParam("number") Integer number, List body) throws IOException { + @PathParam("number") Integer number, List body) throws IOException { response.setTimeout(10, TimeUnit.SECONDS); if(githubService.isOwner(owner, name, number)) { @@ -90,6 +92,7 @@ public void updateOrdering( } else { response.resume(new NotAuthorizedException("Only the owner of a pull request can edit it's ordering")); } + } /** diff --git a/src/main/java/previewcode/backend/api/v1/StatusAPI.java b/src/main/java/previewcode/backend/api/v1/StatusAPI.java index 3ba60c8..959d9cc 100644 --- a/src/main/java/previewcode/backend/api/v1/StatusAPI.java +++ b/src/main/java/previewcode/backend/api/v1/StatusAPI.java @@ -14,7 +14,7 @@ /** * API endpoint for the status of a pull request */ -@Path("{owner}/{name}/pulls/{branch}/status/") +@Path("v1/{owner}/{name}/pulls/{branch}/status") public class StatusAPI { @Inject diff --git a/src/main/java/previewcode/backend/api/v1/TrackerAPI.java b/src/main/java/previewcode/backend/api/v1/TrackerAPI.java index 9307bb2..5929c44 100644 --- a/src/main/java/previewcode/backend/api/v1/TrackerAPI.java +++ b/src/main/java/previewcode/backend/api/v1/TrackerAPI.java @@ -12,7 +12,7 @@ /** * API endpoint for the status of a pull request */ -@Path("tracker/") +@Path("v1/tracker") public class TrackerAPI { @Inject diff --git a/src/main/java/previewcode/backend/api/v1/WebhookAPI.java b/src/main/java/previewcode/backend/api/v1/WebhookAPI.java index f36c289..404edcf 100644 --- a/src/main/java/previewcode/backend/api/v1/WebhookAPI.java +++ b/src/main/java/previewcode/backend/api/v1/WebhookAPI.java @@ -23,8 +23,7 @@ import java.security.NoSuchAlgorithmException; import java.security.spec.InvalidKeySpecException; - -@Path("webhook/") +@Path("v1/webhook") public class WebhookAPI { private static final Logger logger = LoggerFactory.getLogger(WebhookAPI.class); private static final ObjectMapper mapper = new ObjectMapper(); diff --git a/src/main/java/previewcode/backend/api/v2/OrderingAPI.java b/src/main/java/previewcode/backend/api/v2/OrderingAPI.java new file mode 100644 index 0000000..2225d4c --- /dev/null +++ b/src/main/java/previewcode/backend/api/v2/OrderingAPI.java @@ -0,0 +1,47 @@ +package previewcode.backend.api.v2; + +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import previewcode.backend.DTO.OrderingGroup; +import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.services.IDatabaseService; +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +import previewcode.backend.services.actiondsl.Interpreter; + +import javax.inject.Inject; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Response; + +@Path("v2/{owner}/{name}/pulls/{number}/ordering") +public class OrderingAPI { + + private static final Logger logger = LoggerFactory.getLogger(OrderingAPI.class); + + private final Interpreter interpreter; + private final IDatabaseService databaseService; + + @Inject + public OrderingAPI(Interpreter interpreter, IDatabaseService databaseService) { + this.interpreter = interpreter; + this.databaseService = databaseService; + } + + @POST + public Response updateOrdering( + @PathParam("owner") String owner, + @PathParam("name") String name, + @PathParam("number") Integer number, + java.util.List body + ) throws Exception { + + PullRequestIdentifier pull = new PullRequestIdentifier(owner, name, number); + Action action = databaseService.updateOrdering(pull, List.ofAll(body)); + return interpreter.evaluateToResponse(action); + } + +} diff --git a/src/main/java/previewcode/backend/api/v2/TestAPI.java b/src/main/java/previewcode/backend/api/v2/TestAPI.java new file mode 100644 index 0000000..8b2e7cb --- /dev/null +++ b/src/main/java/previewcode/backend/api/v2/TestAPI.java @@ -0,0 +1,35 @@ +package previewcode.backend.api.v2; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import java.util.Date; + +@Path("v2/test") +public class TestAPI { + + static class Response { + @JsonProperty("version") + public String apiVersion = "v2"; + @JsonProperty("time") + public String serverTime = new Date().toString(); + + @JsonCreator + public Response(@JsonProperty("version") String version, @JsonProperty("time") String serverTime) { + this.apiVersion = version; + this.serverTime = serverTime; + } + + public Response() { } + } + + + @GET + @Produces("application/json") + public Response get() { + return new Response(); + } +} diff --git a/src/main/java/previewcode/backend/database/DatabaseException.java b/src/main/java/previewcode/backend/database/DatabaseException.java new file mode 100644 index 0000000..c5b29cc --- /dev/null +++ b/src/main/java/previewcode/backend/database/DatabaseException.java @@ -0,0 +1,15 @@ +package previewcode.backend.database; + +public class DatabaseException extends RuntimeException { + public DatabaseException(String reason) { + super(reason); + } + + public DatabaseException(String message, Throwable cause) { + super(message, cause); + } + + public DatabaseException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/previewcode/backend/database/DatabaseID.java b/src/main/java/previewcode/backend/database/DatabaseID.java new file mode 100644 index 0000000..31ce4c5 --- /dev/null +++ b/src/main/java/previewcode/backend/database/DatabaseID.java @@ -0,0 +1,38 @@ +package previewcode.backend.database; + +/** + * Represents an identifier of some entity in the database. + */ +public class DatabaseID { + + public final Long id; + + public DatabaseID(Long id) { + this.id = id; + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DatabaseID that = (DatabaseID) o; + + return id.equals(that.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public String toString() { + return this.getClass().getSimpleName() +"{" + + "id=" + id + + '}'; + } + + +} diff --git a/src/main/java/previewcode/backend/database/DatabaseInterpreter.java b/src/main/java/previewcode/backend/database/DatabaseInterpreter.java new file mode 100644 index 0000000..d99a42b --- /dev/null +++ b/src/main/java/previewcode/backend/database/DatabaseInterpreter.java @@ -0,0 +1,74 @@ +package previewcode.backend.database; + +import io.vavr.collection.List; +import org.jooq.DSLContext; +import org.jooq.Record1; +import org.jooq.exception.DataAccessException; +import org.postgresql.util.PSQLException; +import previewcode.backend.services.actiondsl.Interpreter; +import previewcode.backend.services.actions.DatabaseActions; + +import javax.inject.Inject; + +import static previewcode.backend.database.model.Tables.*; +import static previewcode.backend.services.actions.DatabaseActions.*; + +public class DatabaseInterpreter extends Interpreter { + + private final DSLContext db; + + @Inject + public DatabaseInterpreter(DSLContext db) { + this.db = db; + on(FetchPull.class).apply(this::fetchPullRequest); + on(InsertPullIfNotExists.class).apply(this::insertPull); + on(NewGroup.class).apply(this::insertNewGroup); + on(FetchGroupsForPull.class).apply(this::fetchGroups); + } + + + protected PullRequestID insertPull(InsertPullIfNotExists action) { + try { + return new PullRequestID( + db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(action.owner, action.name, action.number) + .returning(PULL_REQUEST.ID) + .fetchOne().getId() + ); + } catch (DataAccessException e) { + if (e.getCause() instanceof PSQLException && ((PSQLException) e.getCause()).getSQLState().equals("23505")) { + return this.fetchPullRequest(fetchPull(action.owner, action.name, action.number)); + } else { + throw e; + } + } + } + + protected PullRequestID fetchPullRequest(FetchPull action) { + Record1 pullIdRecord = db.select(PULL_REQUEST.ID) + .from(PULL_REQUEST) + .where( PULL_REQUEST.OWNER.eq(action.owner) + .and(PULL_REQUEST.NAME.eq(action.name)) + .and(PULL_REQUEST.NUMBER.eq(action.number))) + .fetchAny(); + + if (pullIdRecord != null) { + return new PullRequestID(pullIdRecord.value1()); + } else { + throw new DatabaseException("Could not find pull request with identifier: " + + action.owner + "/" + action.name + "/" + action.number); + } + } + + protected GroupID insertNewGroup(NewGroup newGroup) { + return new GroupID( + db.insertInto(GROUPS, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(newGroup.pullRequestId.id, newGroup.title, newGroup.description) + .returning(GROUPS.ID).fetchOne().getId() + ); + } + + protected List fetchGroups(FetchGroupsForPull fetchGroupsForPull) { + return null; + } +} diff --git a/src/main/java/previewcode/backend/database/GroupID.java b/src/main/java/previewcode/backend/database/GroupID.java new file mode 100644 index 0000000..5de6392 --- /dev/null +++ b/src/main/java/previewcode/backend/database/GroupID.java @@ -0,0 +1,7 @@ +package previewcode.backend.database; + +public class GroupID extends DatabaseID { + public GroupID(Long id) { + super(id); + } +} diff --git a/src/main/java/previewcode/backend/database/HunkID.java b/src/main/java/previewcode/backend/database/HunkID.java new file mode 100644 index 0000000..95bf97c --- /dev/null +++ b/src/main/java/previewcode/backend/database/HunkID.java @@ -0,0 +1,41 @@ +package previewcode.backend.database; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO representing a group of hunks as stored in the database. + */ +public class HunkID { + + @JsonProperty("hunkID") + public final String hunkID; + + + @JsonCreator + public HunkID(@JsonProperty("hunkID") String hunkID) { + this.hunkID = hunkID; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + HunkID hunkID1 = (HunkID) o; + + return hunkID.equals(hunkID1.hunkID); + } + + @Override + public int hashCode() { + return hunkID.hashCode(); + } + + @Override + public String toString() { + return "HunkID{" + + "hunkID='" + hunkID + '\'' + + '}'; + } +} diff --git a/src/main/java/previewcode/backend/database/PullRequestGroup.java b/src/main/java/previewcode/backend/database/PullRequestGroup.java new file mode 100644 index 0000000..5198483 --- /dev/null +++ b/src/main/java/previewcode/backend/database/PullRequestGroup.java @@ -0,0 +1,68 @@ +package previewcode.backend.database; + +import io.vavr.collection.List; + +import static previewcode.backend.services.actiondsl.ActionDSL.*; +import static previewcode.backend.services.actions.DatabaseActions.*; + +/** + * DTO representing a group of hunks as stored in the database. + */ +public class PullRequestGroup { + + /** + * The id of the group + */ + public final GroupID id; + + /** + * The title of the object + */ + public final String title; + + /** + * The description of the object + */ + public final String description; + + + /** + * Evaluating this action should result in the list of + * hunk-ids of all hunks in this group. + */ + public final Action> fetchHunks; + + public PullRequestGroup(GroupID id, String title, String description) { + this.id = id; + this.title = title; + this.description = description; + this.fetchHunks = fetchHunks(id); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + PullRequestGroup that = (PullRequestGroup) o; + + return id.equals(that.id) && title.equals(that.title) && description.equals(that.description); + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + title.hashCode(); + result = 31 * result + description.hashCode(); + return result; + } + + @Override + public String toString() { + return "PullRequestGroup{" + + "id=" + id + + ", title='" + title + '\'' + + ", description='" + description + '\'' + + '}'; + } +} diff --git a/src/main/java/previewcode/backend/database/PullRequestID.java b/src/main/java/previewcode/backend/database/PullRequestID.java new file mode 100644 index 0000000..4d8a151 --- /dev/null +++ b/src/main/java/previewcode/backend/database/PullRequestID.java @@ -0,0 +1,7 @@ +package previewcode.backend.database; + +public class PullRequestID extends DatabaseID { + public PullRequestID(Long id) { + super(id); + } +} diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java new file mode 100644 index 0000000..1fdafc0 --- /dev/null +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -0,0 +1,46 @@ +package previewcode.backend.services; + + +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; +import io.vavr.collection.Seq; +import previewcode.backend.DTO.OrderingGroup; +import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.database.PullRequestGroup; +import previewcode.backend.database.PullRequestID; +import previewcode.backend.services.actions.DatabaseActions; + +import java.util.function.Function; +import static previewcode.backend.services.actiondsl.ActionDSL.*; +import static previewcode.backend.services.actions.DatabaseActions.*; + +public class DatabaseService implements IDatabaseService { + + @Override + public Action updateOrdering(PullRequestIdentifier pull, Seq groups) { + return insertPullIfNotExists(pull) + .then(this::clearExistingGroups) + .then(dbPullId -> traverse(groups, createGroup(dbPullId))).toUnit(); + } + + @Override + public Action> fetchPullRequestGroups(PullRequestIdentifier pull) { + return fetchPull(pull).then(DatabaseActions::fetchGroups); + } + + + + public Function> createGroup(PullRequestID dbPullId) { + return group -> + newGroup(dbPullId, group.info.title, group.info.description).then( + groupID -> traverse(List.ofAll(group.diff), hunkId -> assignToGroup(groupID, hunkId)) + ).toUnit(); + } + + public Action clearExistingGroups(PullRequestID dbPullId) { + return fetchGroups(dbPullId) + .then(traverse(group -> delete(group.id))) + .map(unit -> dbPullId); + } + +} diff --git a/src/main/java/previewcode/backend/services/FirebaseService.java b/src/main/java/previewcode/backend/services/FirebaseService.java index a879507..ac5a57b 100644 --- a/src/main/java/previewcode/backend/services/FirebaseService.java +++ b/src/main/java/previewcode/backend/services/FirebaseService.java @@ -7,11 +7,10 @@ import org.slf4j.LoggerFactory; import org.slf4j.Logger; import previewcode.backend.DTO.Approve; -import previewcode.backend.DTO.Ordering; +import previewcode.backend.DTO.OrderingGroup; import previewcode.backend.DTO.PullRequestIdentifier; import previewcode.backend.DTO.Track; -import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.function.Function; @@ -151,7 +150,7 @@ public void onCancelled(DatabaseError error) { * * @param pullId The identifier object for the pull request */ - public void setOrdering(final PullRequestIdentifier pullId, List orderings) { + public void setOrdering(final PullRequestIdentifier pullId, List orderings) { DatabaseReference path = this.ref .child(pullId.owner) diff --git a/src/main/java/previewcode/backend/services/IDatabaseService.java b/src/main/java/previewcode/backend/services/IDatabaseService.java new file mode 100644 index 0000000..0c4324b --- /dev/null +++ b/src/main/java/previewcode/backend/services/IDatabaseService.java @@ -0,0 +1,16 @@ +package previewcode.backend.services; + +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; +import io.vavr.collection.Seq; +import previewcode.backend.DTO.OrderingGroup; +import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.database.PullRequestGroup; + +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +public interface IDatabaseService { + Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, Seq body); + + Action> fetchPullRequestGroups(PullRequestIdentifier pull); +} diff --git a/src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java b/src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java new file mode 100644 index 0000000..c291ac9 --- /dev/null +++ b/src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java @@ -0,0 +1,273 @@ +package previewcode.backend.services.actiondsl; + +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; +import io.vavr.collection.Seq; +import java.util.function.Consumer; +import java.util.function.Function; + +public class ActionDSL { + + /** + * An action that does nothing. + * Used mainly in testing. + * + * @param + */ + public static class NoOp extends Action { } + + /** + * Represents an action that a service can take. + * Actions can be combined with several methods to + * support any kind of control flow over actions. + * + * @param Represents the result type of the action + */ + public static abstract class Action { + + /** + * Discard the result of the current action. + * @return An action with the same effect that returns {@code Unit}. + */ + public Action toUnit() { + return this.map(x -> unit); + } + + /** + * Discard the result of the current action, and instead return {@code value}. + * @param value The value to return + * @param Type of the new value + * @return An action with the same effect that returns {@code value} + */ + public Action pure(B value) { + return this.map(x -> value); + } + + /** + * Given a function {@code A -> B}, map this function over an Action. + * In other words, map will lift a pure function over an Action. * + * + * @param f The function to lift over the current action. + * @param The resulting action return type + * @return The transformed action + */ + public Action map(Function f) { + return new Apply<>(new Return<>(f), this); + } + + + /** + * This method enables combination of multiple independent action results. + * + * {@code ap} is quite a 'low level' operator, so most of the time it will + * be more convenient to use one of the following (which use {@code ap} internally): + *
+ * {@link ActionDSL#sequence(Seq)},
+ * {@link ActionDSL#traverse(Function)} or
+ * {@link ActionDSL#traverse(Seq, Function)} + *
+ *
+ * Example: + *
+         * {@code
+         *
+         *  Action four = pure(4);
+         *  Action three = pure(3);
+         *  Action two = pure(2);
+         *  Action one = pure(1);
+         *
+         *  // Result: 10
+         *  Action applied = one.ap(two.ap(three.ap(four.map(d -> c -> b -> a -> a+b+c+d))));
+         * }
+         * 
+ */ + public Action ap(Action> f) { + return new Apply<>(f ,this); + } + + + /** + * Compose functions that return Actions. + * + * This allows for sequential computation on Actions: + * + *
+         * {@code
+         *
+         *  | Compute Action
+         *  |
+         *  |      | Take  and run the next Action
+         *  |      |
+         *  |      |       | Take  and run the next Action
+         *  |      |       |
+         *  |      |       |       | Take the resulting  and perform the last action
+         *  a.then(f).then(g).then(h)
+         * }
+         * 
+ * + * + * @param f Function that takes the result of the current action, and returns the next action to run. + * @param The type of the final action + * @return The composed action + */ + public Action then(Function> f) { + return new Suspend<>(f, this); + } + + + public Action then(Action next) { + return then(x -> next); + } + + @Override + public String toString() { + return this.getClass().getSimpleName(); + } + } + + public static class Return extends Action { + public final A value; + + public Return(A value) { + this.value = value; + } + + @Override + public Action map(Function f) { + return new Return<>(f.apply(this.value)); + } + + @Override + public Action ap(Action> f) { + return f.map(fAB -> fAB.apply(value)); + } + + @Override + public Action then(Function> f) { + return f.apply(this.value); + } + } + + public static class Apply extends Action { + public final Action> f; + public final Action action; + + public Apply(Action> f, Action action) { + this.f = f; + this.action = action; + } + + @Override + public Action map(Function f) { + return new Apply<>(this.f.map(f::compose), action); + } + + @Override + public Action ap(Action> f) { + return new Apply<>(this.f.ap(f.map(fBC -> fBC::compose)), action); + } + + @Override + public Action then(Function> f) { + return new Suspend<>(f, action.then(x -> this.f.map(fx -> fx.apply(x)))); + } + } + + public static class Suspend extends Action { + public final Action action; + public final Function> f; + + public Suspend(Function> f, Action action) { + this.action = action; + this.f = f; + } + + @Override + public Action map(Function f) { + return new Suspend<>(this.f.andThen(next -> next.map(f)), this.action); + } + + @Override + public Action then(Function> f) { + Action next = this.action.then(this.f); + return new Suspend<>(f, next); + } + } + + /** + * Lift a value into the context of an Action. + * The result will be an Action that when evaluated performs no effect. + * Therefore this action is 'pure', and will only return {@code value}. + * + * @param value The value to lift + * @param Type of the lifted value + * @return A pure action + */ + public static Action pure(A value) { + return new Return<>(value); + } + + /** + * Take a sequence of actions and turn them into a single action. + * + * @param actions A sequence of independent actions + * @param The type of each action + * @return A single action that returns if all sequenced actions have completed. + */ + public static Action> sequence(Seq> actions) { + Function, ? extends Seq>> cons = x -> xs -> xs.append(x); + return actions.map(a -> a.map(cons)).foldLeft(pure(List.empty()), Action::ap); + } + + /** + * Given a function that generates an action, and a sequence of values, + * map each value to an action and then sequence these actions. + * + * @param xs The sequence of values to map + * @param f Function to turn each value into an action + * @param Type of values + * @param Result type of the actions + * @return A single action that returns if all sequenced actions have completed. + */ + public static Action> traverse(Seq xs, Function> f) { + return sequence(xs.map(f)); + } + + /** + * Curried version of {@link #traverse(Seq, Function)}. + */ + public static Function, Action>> traverse(Function> f) { + return xs -> sequence(xs.map(f)); + } + + /** + * Action that returns Unit. + * This is analogous to a method that does nothing and returns void. + */ + public static final Action returnU = new Return<>(Unit.VALUE); + + /** + * The Unit data type represents a result of a computation with no information. + * Serves the same purpose as Java's `void`, but since void/{@link Void} is not a type and has no values, + * it's impossible to represent {@code Action}. + * + * Instead, an action that returns nothing is represented as {@code Action}. + */ + public static final Unit unit = Unit.VALUE; + + /** + * Takes a consumer, which is essentially a {@code Function}, + * and represent it as a {@code Function}. + */ + public static Function toUnit(Consumer f) { + return a -> { + f.accept(a); + return unit; + }; + } + + public static Interpreter interpret() { + return new Interpreter(); + } +} + diff --git a/src/main/java/previewcode/backend/services/actiondsl/ActionMain.java b/src/main/java/previewcode/backend/services/actiondsl/ActionMain.java new file mode 100644 index 0000000..93a6a1b --- /dev/null +++ b/src/main/java/previewcode/backend/services/actiondsl/ActionMain.java @@ -0,0 +1,83 @@ +package previewcode.backend.services.actiondsl; + +import io.atlassian.fugue.Unit; +import previewcode.backend.DTO.*; + +import static previewcode.backend.services.actiondsl.ActionDSL.*; +import static previewcode.backend.services.actiondsl.ActionDSL.Action; +import static previewcode.backend.services.actions.GitHubActions.*; +import static previewcode.backend.services.actions.LogActions.*; + + + +class GitHubInterpreter extends Interpreter { + + final GitHubPullRequest pull = + new GitHubPullRequest("pull title", "these changes are awesome", "https://lol.com/", 123, + new PullRequestLinks("http://self", "http://html", "http://issue", "http://comments", + "http://statuses", "http://review_comments", "http://review_comment", "http://commits")); + + final GitHubStatus status = new GitHubStatus("pending", "Changes need ordering", + "preview-code/ordering", "http://status-ordering"); + + + public GitHubInterpreter() { + super(); + + on(GitHubFetchPullRequest.class).apply(fetchPull -> { + System.out.println("Pull!"); + return pull; + }); + + on(GitHubGetStatus.class).apply(getAction -> { + System.out.println("Getting status"); + return status; + }); + + on(GitHubSetStatus.class).apply(toUnit(setAction -> + System.out.println("Setting status") + )); + + on(GitHubPostComment.class).apply(toUnit(postAction -> + System.out.println("Posting comment") + )); + } +} + +class LogActionInterpreter extends Interpreter { + + public LogActionInterpreter() { + super(); + + on(LogInfo.class).apply(toUnit(logInfo -> + System.out.println("INFO: " + logInfo.message) + )); + } +} + +class ActionMain { + + public static void main(String... args) { + + Action gitHubAction = + new GitHubFetchPullRequest("preview-code", "backend", 42) // Get the PR from GitHub + .then(pullRequest -> new GitHubGetStatus(pullRequest) // Get the status of this PR + .then(maybeStatus -> + OrderingStatus.fromGitHubStatus(maybeStatus) + .map(OrderingStatus::complete) // If the status exists, set it to 'complete' + .map(status -> // Then post a comment and the new status + new GitHubPostComment(pullRequest, "This PR is now completed!") + .then(done -> new GitHubSetStatus(pullRequest, status)) + ) + .orElse(returnU))); // If the status is not there, just do nothing + + + Action composedAction = + new GitHubFetchPullRequest("preview-code", "backend", 42) + .then(p -> new LogInfo("Fetched PR")); + + new Interpreter(new GitHubInterpreter()) + .run(composedAction); + } + +} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java new file mode 100644 index 0000000..71c5ec3 --- /dev/null +++ b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java @@ -0,0 +1,194 @@ +package previewcode.backend.services.actiondsl; + +import io.atlassian.fugue.Either; +import io.atlassian.fugue.Try; +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; + +import javax.ws.rs.core.Response; + +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + + +public class Interpreter { + private final Map, Object> handlers; + + public static class InterpreterBuilder> { + + private final Interpreter interpreter; + private final Class actionClass; + + protected InterpreterBuilder(Interpreter interpreter, Class actionClass) { + this.interpreter = interpreter; + this.actionClass = actionClass; + } + + /** + * When this action is encountered, simply return {@code value}. + * + * @param value The value to return + * @return The interpreter that returns {@code value} for action {@code X} + */ + public Interpreter returnA(A value) { + return apply(action -> value); + } + + /** + * When this action is encountered, run the {@code handler} function. + * @param handler Function that handles actions of type {@code X} + * @return An interpreter that handles {@code X} + */ + public Interpreter apply(Function handler) { + interpreter.handlers.put(actionClass, handler); + return interpreter; + } + + /** + * When this action is encountered, halt the execution with + * a {@code StoppedException}. + * + * @param handler The function to run before raising a {@code StoppedException}. + * @return An interpreter that halts on {@code X} + */ + public Interpreter stop(Consumer handler) { + return apply(x -> { + handler.accept(x); + throw new StoppedException(); + }); + } + + /** + * When this action is encountered, halt the execution with + * a {@code StoppedException}. + * + * @return An interpreter that halts on {@code X} + */ + public Interpreter stop() { + return this.stop(action -> {}); + } + } + + public static class StoppedException extends RuntimeException { } + + public Interpreter() { + handlers = new HashMap<>(); + } + + /** + * Compose multiple interpreters. + * + * If multiple interpreter handle the same action, + * the handler of the last interpreter will be called. + */ + public Interpreter(Interpreter ... interpreters) { + this(); + List.of(interpreters).forEach(interpreter -> handlers.putAll(interpreter.handlers)); + } + + /** + * Build an interpreter that recognizes {@code actionClass} + * + * @param actionClass The action that needs to be interpreted + * @param The actions return type + * @param The action type + * @return A Builder which can be used to associate a handler with this action. + */ + public > InterpreterBuilder on(Class actionClass) { + return new InterpreterBuilder<>(this, actionClass); + } + + /** + * Build an interpreter that does nothing for the given action. + * + * The action return type must be {@code Unit} for the + * interpreter to be able to ignore the action. + * + * @param actionClass The action to ignore when evaluating. + * @return The new interpreter + */ + public Interpreter ignore(Class> actionClass) { + this.handlers.put(actionClass, toUnit(a -> {})); + return this; + } + + + /** + * Evaluate an action with the current interpreter. + * + * @param action The action to run + * @param Represents the result type + * @return A Try monad encapsulating any errors that occur during evaluation + */ + public Try evaluate(Action action) { + return run(action); + } + + /** + * Evaluate an action with the current interpreter. + * This method does not encapsulate errors in {@code Try}, + * which means that any error will be thrown immediately. + * + * @param action The action to run + * @param Represents the result type + * @return The result of running the action + * @throws Exception when an error occurs during evaluation + */ + public A unsafeEvaluate(Action action) throws Exception { + Either result = evaluate(action).toEither(); + + if (result.isLeft()) { + throw result.left().get(); + } else { + return result.right().get(); + } + } + + /** + * Evaluate an action and build a response from the action result. + * @param action The action to evaluate + * @return The response built from the action result. + * @throws Exception when an error occurs during evaluation + */ + public Response evaluateToResponse(Action action) throws Exception { + return Response.ok().entity(unsafeEvaluate(action)).build(); + } + + @SuppressWarnings("unchecked") + protected Try run(Action action) { + if (action == null) return Try.failure(new NullPointerException("Attempting to run a null action.")); + + if (action instanceof Return) { + return Try.successful(((Return) action).value); + } else if (action instanceof Suspend) { + Suspend suspendedAction = (Suspend) action; + return run(suspendedAction.action) + .flatMap(suspendedAction.f.andThen(this::run)); + } else if (action instanceof Apply) { + Apply applyAction = (Apply) action; + Try applicant = run(applyAction.action); + Try> applier = run(applyAction.f); + return applicant.flatMap(x -> applier.map(fXA -> fXA.apply(x))); + } else { + if (handlers.containsKey(action.getClass())) { + Function, ? extends A> handler = (Function, ? extends A>) handlers.get(action.getClass()); + try { + A result = handler.apply(action); + if (result != null) { + return Try.successful(result); + } else { + throw new RuntimeException("Action handler for " + action.getClass().getSimpleName() + " returned null."); + } + } catch (Exception e) { + return Try.failure(e); + } + } else { + return Try.failure(new RuntimeException("Unexpected action: " + action)); + } + } + } +} diff --git a/src/main/java/previewcode/backend/services/actions/DatabaseActions.java b/src/main/java/previewcode/backend/services/actions/DatabaseActions.java new file mode 100644 index 0000000..ecf3b4a --- /dev/null +++ b/src/main/java/previewcode/backend/services/actions/DatabaseActions.java @@ -0,0 +1,116 @@ +package previewcode.backend.services.actions; + +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; +import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.database.GroupID; +import previewcode.backend.database.HunkID; +import previewcode.backend.database.PullRequestGroup; +import previewcode.backend.database.PullRequestID; +import previewcode.backend.services.actiondsl.ActionDSL.*; + +public class DatabaseActions { + + public static class DatabaseAction extends Action { } + + public static class FetchPull extends DatabaseAction { + public final String owner; + public final String name; + public final Integer number; + + public FetchPull(PullRequestIdentifier pullRequestIdentifier) { + this.owner = pullRequestIdentifier.owner; + this.name = pullRequestIdentifier.name; + this.number = pullRequestIdentifier.number; + } + } + + public static class InsertPullIfNotExists extends FetchPull { + public InsertPullIfNotExists(PullRequestIdentifier pullRequestIdentifier) { + super(pullRequestIdentifier); + } + } + + public static class NewGroup extends DatabaseAction { + + public final PullRequestID pullRequestId; + public final String title; + public final String description; + + public NewGroup(PullRequestID pullRequestId, String title, String description) { + this.pullRequestId = pullRequestId; + this.title = title; + this.description = description; + } + } + + public static class AssignHunkToGroup extends DatabaseAction { + + public final GroupID groupID; + public final String hunkIdentifier; + + public AssignHunkToGroup(GroupID groupID, String hunkIdentifier) { + this.groupID = groupID; + this.hunkIdentifier = hunkIdentifier; + } + } + + public static class FetchGroupsForPull extends DatabaseAction> { + public final PullRequestID pullRequestID; + + public FetchGroupsForPull(PullRequestID pullRequestID) { + this.pullRequestID = pullRequestID; + } + } + + public static class FetchHunksForGroup extends DatabaseAction> { + + public final GroupID groupID; + + public FetchHunksForGroup(GroupID groupID) { + this.groupID = groupID; + } + } + + public static class DeleteGroup extends DatabaseAction { + public final GroupID groupID; + + public DeleteGroup(GroupID groupID) { + this.groupID = groupID; + } + } + + + + public static InsertPullIfNotExists insertPullIfNotExists(PullRequestIdentifier pull) { + return new InsertPullIfNotExists(pull); + } + + public static FetchPull fetchPull(PullRequestIdentifier pull) { + return new FetchPull(pull); + } + + public static FetchPull fetchPull(String owner, String name, Integer number) { + return new FetchPull(new PullRequestIdentifier(owner, name, number)); + } + + public static NewGroup newGroup(PullRequestID pullRequestId, String title, String description) { + return new NewGroup(pullRequestId, title, description); + } + + public static AssignHunkToGroup assignToGroup(GroupID groupID, String hunkId) { + return new AssignHunkToGroup(groupID, hunkId); + } + + public static FetchGroupsForPull fetchGroups(PullRequestID pullRequestID) { + return new FetchGroupsForPull(pullRequestID); + } + + public static FetchHunksForGroup fetchHunks(GroupID groupID) { + return new FetchHunksForGroup(groupID); + } + + public static DeleteGroup delete(GroupID groupID) { + return new DeleteGroup(groupID); + } +} diff --git a/src/main/java/previewcode/backend/services/actions/GitHubActions.java b/src/main/java/previewcode/backend/services/actions/GitHubActions.java new file mode 100644 index 0000000..aa1c7bf --- /dev/null +++ b/src/main/java/previewcode/backend/services/actions/GitHubActions.java @@ -0,0 +1,52 @@ +package previewcode.backend.services.actions; + +import io.atlassian.fugue.Unit; +import previewcode.backend.DTO.GitHubPullRequest; +import previewcode.backend.DTO.GitHubStatus; +import previewcode.backend.DTO.PullRequestIdentifier; + +import static previewcode.backend.services.actiondsl.ActionDSL.Action; + +public class GitHubActions { + + public static class GitHubGetStatus extends Action { + public final GitHubPullRequest pullRequest; + + public GitHubGetStatus(GitHubPullRequest pullRequest) { + this.pullRequest = pullRequest; + } + } + + public static class GitHubSetStatus extends Action { + public final GitHubPullRequest pullRequest; + public final GitHubStatus status; + + public GitHubSetStatus(GitHubPullRequest pullRequest, GitHubStatus status) { + this.pullRequest = pullRequest; + this.status = status; + } + } + + public static class GitHubFetchPullRequest extends Action { + public final PullRequestIdentifier pull; + + GitHubFetchPullRequest(PullRequestIdentifier pull) { + this.pull = pull; + } + + public GitHubFetchPullRequest(String owner, String name, Integer number) { + this(new PullRequestIdentifier(owner, name, number)); + } + } + + public static class GitHubPostComment extends Action { + public final String postUrl; + public final String comment; + + public GitHubPostComment(GitHubPullRequest pull, String comment) { + this.postUrl = pull.links.comments; + this.comment = comment; + } + } + +} diff --git a/src/main/java/previewcode/backend/services/actions/LogActions.java b/src/main/java/previewcode/backend/services/actions/LogActions.java new file mode 100644 index 0000000..0cff5fd --- /dev/null +++ b/src/main/java/previewcode/backend/services/actions/LogActions.java @@ -0,0 +1,17 @@ +package previewcode.backend.services.actions; +import io.atlassian.fugue.Unit; +import previewcode.backend.services.actiondsl.ActionDSL.*; + +public abstract class LogActions extends Action { + public final String message; + + protected LogActions(String message) { + this.message = message; + } + + public static class LogInfo extends LogActions { + public LogInfo(String message) { + super(message); + } + } +} diff --git a/src/main/resources/db-migration/V1__initialise_database.sql b/src/main/resources/db-migration/V1__initialise_database.sql new file mode 100644 index 0000000..1cb10ee --- /dev/null +++ b/src/main/resources/db-migration/V1__initialise_database.sql @@ -0,0 +1,46 @@ +DROP SCHEMA IF EXISTS preview_code CASCADE; +CREATE SCHEMA preview_code; + +CREATE SEQUENCE preview_code.seq_pk_pull_request; + +CREATE TABLE preview_code.pull_request ( + id BIGINT DEFAULT nextval('preview_code.seq_pk_pull_request') NOT NULL CONSTRAINT pk_pull_request PRIMARY KEY, + owner VARCHAR NOT NULL, + name VARCHAR NOT NULL, + number INT NOT NULL +); + +CREATE UNIQUE INDEX pull_owner_name_number ON preview_code.pull_request ( + owner, name, number +); + + +CREATE SEQUENCE preview_code.seq_pk_groups; + +CREATE TABLE preview_code.groups ( + id BIGINT DEFAULT nextval('preview_code.seq_pk_groups') NOT NULL CONSTRAINT pk_groups PRIMARY KEY, + title VARCHAR NOT NULL, + description VARCHAR NOT NULL, + pull_request_id BIGINT NOT NULL CONSTRAINT fk_groups_pull_request REFERENCES preview_code.pull_request(id) +); + +CREATE INDEX fk_group_pull_id ON preview_code.groups (pull_request_id); + + +CREATE TABLE preview_code.hunk ( + id VARCHAR NOT NULL, + group_id BIGINT NOT NULL CONSTRAINT fk_hunk_group_id REFERENCES preview_code.groups(id), + + CONSTRAINT unique_hunkId_groupId UNIQUE (id, group_id) +); + +CREATE INDEX idx_fk_hunk_group_id ON preview_code.hunk (group_id); + + +CREATE TABLE preview_code.approval ( + pull_request_id BIGINT NOT NULL CONSTRAINT fk_approval_pull_request REFERENCES preview_code.pull_request(id), + hunk_id VARCHAR NOT NULL, + + CONSTRAINT pk_approval PRIMARY KEY (pull_request_id, hunk_id) +) + diff --git a/src/main/resources/log4j2-test.yaml b/src/main/resources/log4j2-test.yaml index fa2075d..75fd6b7 100644 --- a/src/main/resources/log4j2-test.yaml +++ b/src/main/resources/log4j2-test.yaml @@ -17,6 +17,20 @@ Configuration: additivity: false AppenderRef: ref: STDOUT + logger: + - + name: org.jooq.tools + level: DEBUG + additivity: false + AppenderRef: + ref: STDOUT + logger: + - + name: com.jolbox.bonecp + level: DEBUG + additivity: false + AppenderRef: + ref: STDOUT Root: level: INFO AppenderRef: diff --git a/src/main/resources/log4j2.yaml b/src/main/resources/log4j2.yaml index 137e93a..e2d0759 100644 --- a/src/main/resources/log4j2.yaml +++ b/src/main/resources/log4j2.yaml @@ -17,6 +17,13 @@ Configuration: additivity: false AppenderRef: ref: STDOUT + logger: + - + name: org.jooq.tools + level: WARN + additivity: false + AppenderRef: + ref: STDOUT Root: level: INFO AppenderRef: diff --git a/src/test/java/previewcode/backend/api/v2/EndPointTest.java b/src/test/java/previewcode/backend/api/v2/EndPointTest.java new file mode 100644 index 0000000..8a4b211 --- /dev/null +++ b/src/test/java/previewcode/backend/api/v2/EndPointTest.java @@ -0,0 +1,82 @@ +package previewcode.backend.api.v2; + +import io.atlassian.fugue.Unit; +import io.vavr.collection.Seq; +import org.junit.jupiter.api.Test; +import previewcode.backend.APIModule; +import previewcode.backend.DTO.OrderingGroup; +import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.test.helpers.ApiEndPointTest; +import previewcode.backend.database.PullRequestGroup; +import previewcode.backend.services.IDatabaseService; +import previewcode.backend.services.actiondsl.ActionDSL.*; +import previewcode.backend.services.actiondsl.Interpreter; + +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.Response; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +@ApiEndPointTest(TestModule.class) +public class EndPointTest { + + @Test + public void testApiIsReachable(WebTarget target) { + Response response = target + .path("/v2/test") + .request("application/json") + .get(); + + assertThat(response.getStatus()).isEqualTo(200); + + TestAPI.Response apiResponse = response.readEntity(TestAPI.Response.class); + assertThat(apiResponse.apiVersion).isEqualTo("v2"); + } + + @Test + public void orderingApiIsReachable(WebTarget target) { + List emptyList = new ArrayList<>(); + + Response response = target + .path("/v2/preview-code/backend/pulls/42/ordering") + .request("application/json") + .post(Entity.json(emptyList)); + + assertThat(response.getLength()).isZero(); + assertThat(response.getStatus()).isEqualTo(200); + } +} + +class TestModule extends APIModule implements IDatabaseService { + + public TestModule() {} + + @Override + public Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, Seq body) { + return new NoOp<>(); + } + + @Override + public Action> fetchPullRequestGroups(PullRequestIdentifier pull) { + return new NoOp<>(); + } + + + @SuppressWarnings("unchecked") + @Override + public void configureServlets() { + super.configureServlets(); + // The DatabaseService always returns a no-op action + this.bind(IDatabaseService.class).toInstance(this); + + // The interpreter always evaluates any action to Unit + this.bind(Interpreter.class).toInstance( + new Interpreter().on(NoOp.class).apply(x -> unit) + ); + } +} diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java new file mode 100644 index 0000000..42a0cf1 --- /dev/null +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java @@ -0,0 +1,29 @@ +package previewcode.backend.database; + +import org.jooq.DSLContext; +import org.junit.jupiter.api.BeforeEach; +import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.services.actiondsl.ActionDSL; +import previewcode.backend.test.helpers.DatabaseTests; + +@DatabaseTests +public class DatabaseInterpreterTest { + + protected DatabaseInterpreter dbInterpreter; + + // Mock data to insert in the database + protected static final String owner = "preview-code"; + protected static final String name = "backend"; + protected static final Integer number = 42; + + protected PullRequestIdentifier pullIdentifier = new PullRequestIdentifier(owner, name, number); + + @BeforeEach + public void setup(DSLContext db) { + this.dbInterpreter = new DatabaseInterpreter(db); + } + + protected T eval(ActionDSL.Action action) throws Exception { + return dbInterpreter.unsafeEvaluate(action); + } +} diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java new file mode 100644 index 0000000..d7fca2b --- /dev/null +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java @@ -0,0 +1,122 @@ +package previewcode.backend.database; + +import org.jooq.DSLContext; +import org.jooq.Record3; +import org.jooq.exception.DataAccessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import previewcode.backend.database.model.tables.records.GroupsRecord; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.Assert.fail; +import static previewcode.backend.database.model.Tables.GROUPS; +import static previewcode.backend.database.model.Tables.PULL_REQUEST; +import static previewcode.backend.services.actions.DatabaseActions.*; + + +public class DatabaseInterpreter_GroupTest extends DatabaseInterpreterTest { + + private static final String groupTitle = "Title"; + private static final String groupDescription = "Description"; + + private static final PullRequestID dbPullId = new PullRequestID(42L); + + // private List groups = List.of( +// new PullRequestGroup(new GroupID(42L), "Group A", "Description A"), +// new PullRequestGroup(new GroupID(24L), "Group B", "Description B") +// ); +// +// private List groupsWithoutHunks = groups.map(group -> +// new OrderingGroupWithID(group, Lists.newLinkedList()) +// ); +// +// private List hunkIDs = List.of( +// new HunkID("abcd"), new HunkID("efgh"), new HunkID("ijkl")); +// +// private List groupsWithHunks = groups.map(group -> +// new OrderingGroupWithID(group, hunkIDs.map(id -> id.hunkID).toJavaList()) +// ); + + @BeforeEach + @Override + public void setup(DSLContext db) { + super.setup(db); + + db.insertInto(PULL_REQUEST, PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(dbPullId.id, owner, name, number) + .execute(); + } + + @Test + public void newGroup_insertsGroup(DSLContext db) throws Exception { + GroupID groupID = eval(newGroup(dbPullId, groupTitle, groupDescription)); + assertThat(groupID.id).isPositive(); + + Integer groupCount = db.selectCount().from(GROUPS).fetchOne().value1(); + assertThat(groupCount).isEqualTo(1); + } + + @Test + public void newGroup_returnsNewId(DSLContext db) throws Exception { + db.insertInto(GROUPS, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(dbPullId.id, "A", "B") + .values(dbPullId.id, "C", "D") + .values(dbPullId.id, "E", "F") + .execute(); + + + GroupID groupID = eval(newGroup(dbPullId, groupTitle, groupDescription)); + + GroupID insertedID = new GroupID(db.select(GROUPS.ID).from(GROUPS).where( + GROUPS.TITLE.eq(groupTitle).and(GROUPS.DESCRIPTION.eq(groupDescription)) + ).fetchOne().value1()); + + assertThat(groupID).isEqualTo(insertedID); + } + + @Test + public void newGroup_canInsertDuplicates(DSLContext db) throws Exception { + NewGroup create = newGroup(dbPullId, groupTitle, groupDescription); + + GroupID groupID = eval(create.then(create)); + + Integer groupCount = db.selectCount().from(GROUPS).fetchOne().value1(); + assertThat(groupCount).isEqualTo(2); + } + + @Test + public void newGroup_insertsCorrectData(DSLContext db) throws Exception { + NewGroup create = newGroup(dbPullId, groupTitle, groupDescription); + eval(create); + + GroupsRecord groupsRecord = db.selectFrom(GROUPS).fetchOne(); + + assertThat(groupsRecord.getPullRequestId()).isEqualTo(create.pullRequestId.id); + assertThat(groupsRecord.getTitle()).isEqualTo(create.title); + assertThat(groupsRecord.getDescription()).isEqualTo(create.description); + } + + @Test + public void newGroup_pullRequestMustExist() { + PullRequestID wrongID = new PullRequestID(0L); + assertThatExceptionOfType(DataAccessException.class) + .isThrownBy(() -> eval(newGroup(wrongID, "A", "B"))); + } + + + @Test + public void fetchGroups_returnsAllGroups() { + fail(); + } + + @Test + public void fetchGroups_pullRequestMustExist() { + fail(); + } + + @Test + public void fetchGroups_fetchesCorrectGroupData() { + fail(); + } +} diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java new file mode 100644 index 0000000..4bef7d4 --- /dev/null +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java @@ -0,0 +1,18 @@ +package previewcode.backend.database; + +import org.junit.jupiter.api.Test; + +import static org.junit.Assert.fail; + +public class DatabaseInterpreter_HunksTest extends DatabaseInterpreterTest { + + @Test + public void assignHunk_groupMustExist() { + fail(); + } + + @Test + public void assignHunk_cannotAssignTwice() { + fail(); + } +} diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java new file mode 100644 index 0000000..ec0ba8c --- /dev/null +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java @@ -0,0 +1,87 @@ +package previewcode.backend.database; + +import org.jooq.DSLContext; +import org.junit.jupiter.api.Test; +import previewcode.backend.DTO.PullRequestIdentifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static previewcode.backend.database.model.Tables.*; +import static previewcode.backend.services.actions.DatabaseActions.*; + +class DatabaseInterpreter_PullRequestTest extends DatabaseInterpreterTest { + + @Test + public void fetchPull_selectsFromPullsTable(DSLContext db) throws Exception { + db.insertInto(PULL_REQUEST, PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(41L, owner, name, number+1) + .values(42L, owner, name, number) + .values(43L, owner, name, number-1) + .execute(); + + PullRequestID pullRequestID = eval(fetchPull(pullIdentifier)); + assertThat(pullRequestID.id).isEqualTo(42L); + } + + @Test + public void fetchPull_throwsWhenPullIsNotFound() throws Exception { + PullRequestIdentifier invalidIdentifier = new PullRequestIdentifier("x", "y", 0); + + assertThatExceptionOfType(DatabaseException.class).isThrownBy( + () -> eval(fetchPull(invalidIdentifier))); + } + + @Test + public void insertPull_definitelyInserts(DSLContext db) throws Exception { + PullRequestID pullRequestID = eval(insertPullIfNotExists(pullIdentifier)); + assertThat(pullRequestID.id).isPositive(); + + Integer pullRequestCount = db.selectCount().from(PULL_REQUEST).fetchOne().value1(); + assertThat(pullRequestCount).isEqualTo(1); + } + + @Test + public void insertPull_returnsNewId(DSLContext db) throws Exception { + db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(owner, name, number+1) + .values(owner, name, number+2) + .values(owner, name, number+3) + .execute(); + + PullRequestID pullRequestID = eval(insertPullIfNotExists(pullIdentifier)); + + PullRequestID insertedId = new PullRequestID( + db.select(PULL_REQUEST.ID) + .from(PULL_REQUEST) + .where(PULL_REQUEST.OWNER.eq(pullIdentifier.owner) + .and(PULL_REQUEST.NAME.eq(pullIdentifier.name)) + .and(PULL_REQUEST.NUMBER.eq(pullIdentifier.number))) + .fetchOne().value1() + ); + + assertThat(pullRequestID).isEqualTo(insertedId); + } + + @Test + public void insertPull_duplicate_doesNotInsert(DSLContext db) throws Exception { + PullRequestID existingId = new PullRequestID( + db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(pullIdentifier.owner, pullIdentifier.name, pullIdentifier.number) + .returning(PULL_REQUEST.ID).fetchOne().getId()); + + Integer pullRequestCount = db.selectCount().from(PULL_REQUEST).fetchOne().value1(); + assertThat(pullRequestCount).isEqualTo(1); + } + + @Test + public void insertPull_duplicate_returnsOldId(DSLContext db) throws Exception { + PullRequestID existingId = new PullRequestID( + db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(pullIdentifier.owner, pullIdentifier.name, pullIdentifier.number) + .returning(PULL_REQUEST.ID).fetchOne().getId()); + + PullRequestID pullRequestID = eval(insertPullIfNotExists(pullIdentifier)); + + assertThat(pullRequestID).isEqualTo(existingId); + } +} \ No newline at end of file diff --git a/src/test/java/previewcode/backend/database/SchemaTest.java b/src/test/java/previewcode/backend/database/SchemaTest.java new file mode 100644 index 0000000..61d86ed --- /dev/null +++ b/src/test/java/previewcode/backend/database/SchemaTest.java @@ -0,0 +1,52 @@ +package previewcode.backend.database; + +import org.jooq.DSLContext; +import org.junit.jupiter.api.Test; +import previewcode.backend.database.model.Sequences; +import previewcode.backend.test.helpers.DatabaseTests; + +import static org.assertj.core.api.Assertions.*; +import static previewcode.backend.database.model.Sequences.*; +import static previewcode.backend.database.model.Tables.*; + +/** + * Test that the schema model is generated using jOOQ, + * and that the local database contains only the schema without any rows. + */ +@DatabaseTests +public class SchemaTest { + + @Test + public void pullRequestTable_isEmpty(DSLContext db) { + int rows = db.select(PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .from(PULL_REQUEST).execute(); + assertThat(rows).isZero(); + } + + @Test + public void groupsTable_isEmpty(DSLContext db) { + int rows = db.select(GROUPS.ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .from(GROUPS).execute(); + assertThat(rows).isZero(); + } + + @Test + public void hunksTable_isEmpty(DSLContext db) { + int rows = db.select(HUNK.ID, HUNK.GROUP_ID) + .from(HUNK).execute(); + assertThat(rows).isZero(); + } + + @Test + public void hasSequence_pullRequestId(DSLContext db) { + db.nextval(SEQ_PK_PULL_REQUEST); + assertThat(db.currval(SEQ_PK_PULL_REQUEST)).isEqualTo(1L); + } + + @Test + public void hasSequence_groupId(DSLContext db) { + db.nextval(Sequences.SEQ_PK_GROUPS); + assertThat(db.currval(SEQ_PK_GROUPS)).isEqualTo(1L); + } + +} diff --git a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java new file mode 100644 index 0000000..96b5c95 --- /dev/null +++ b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java @@ -0,0 +1,152 @@ +package previewcode.backend.services; + +import com.google.common.collect.Lists; +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; +import io.vavr.control.Option; +import org.junit.jupiter.api.Test; +import previewcode.backend.DTO.*; +import previewcode.backend.database.GroupID; +import previewcode.backend.database.HunkID; +import previewcode.backend.database.PullRequestGroup; +import previewcode.backend.database.PullRequestID; +import previewcode.backend.services.actiondsl.Interpreter; + +import java.util.Collection; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.*; +import static previewcode.backend.services.actiondsl.ActionDSL.*; +import static previewcode.backend.services.actions.DatabaseActions.*; + +public class DatabaseServiceTest { + + // Service under test + private IDatabaseService service = new DatabaseService(); + + // Mock data to feed to the service + private static final String owner = "preview-code"; + private static final String name = "backend"; + private static final Integer number = 42; + + private final PullRequestID pullRequestID = new PullRequestID(new Long(number)); + + private PullRequestIdentifier pullIdentifier = new PullRequestIdentifier(owner, name, number); + + private List groups = List.of( + new PullRequestGroup(new GroupID(42L), "Group A", "Description A"), + new PullRequestGroup(new GroupID(24L), "Group B", "Description B") + ); + + private List groupsWithoutHunks= groups.map(group -> + new OrderingGroupWithID(group, Lists.newLinkedList()) + ); + + private List hunkIDs = List.of( + new HunkID("abcd"), new HunkID("efgh"), new HunkID("ijkl")); + + private List groupsWithHunks = groups.map(group -> + new OrderingGroupWithID(group, hunkIDs.map(id -> id.hunkID).toJavaList()) + ); + + + + @Test + public void insertsPullIfNotExists() throws Exception { + Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); + + Consumer assertions = action -> { + assertThat(action.owner).isEqualTo(owner); + assertThat(action.name).isEqualTo(name); + assertThat(action.number).isEqualTo(number); + }; + + Interpreter interpreter = + interpret().on(InsertPullIfNotExists.class).stop(assertions); + + assertThatExceptionOfType(Interpreter.StoppedException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(dbAction)); + } + + @Test + public void removesExistingGroups() throws Exception { + Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); + + Collection removedGroups = Lists.newArrayList(); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(DeleteGroup.class).apply(toUnit(action -> { + assertThat(groups).extracting("id").contains(action.groupID); + removedGroups.add(groups.find(group -> group.id.equals(action.groupID)).get()); + })); + + interpreter.unsafeEvaluate(dbAction); + assertThat(removedGroups) + .hasSameElementsAs(groups) + .hasSameSizeAs(groups); + } + + @Test + public void doesNotRemoveGroups() throws Exception { + Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()); + + interpreter.unsafeEvaluate(dbAction); + } + + @Test + public void insertsNewGroupsWithoutHunks() throws Exception { + Action dbAction = service.updateOrdering(pullIdentifier, groupsWithoutHunks); + + Collection groupsAdded = Lists.newArrayList(); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()) + .on(NewGroup.class).apply(action -> { + assertThat(action.pullRequestId).isEqualTo(pullRequestID); + PullRequestGroup group = groups.find(g -> g.title.equals(action.title)).get(); + assertThat(group.description).isEqualTo(action.description); + groupsAdded.add(group); + return group.id; + }); + + interpreter.unsafeEvaluate(dbAction); + assertThat(groupsAdded) + .hasSameElementsAs(groups) + .hasSameSizeAs(groups); + } + + @Test + public void insertsNewGroupsWithHunks() throws Exception { + Action dbAction = service.updateOrdering(pullIdentifier, groupsWithHunks); + + Collection hunksAdded = Lists.newArrayList(); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()) + .on(NewGroup.class).apply(action -> + groups.find(g -> g.title.equals(action.title)).get().id) + .on(AssignHunkToGroup.class).apply(toUnit(action -> { + assertThat(groups.find(g -> g.id.equals(action.groupID))).isNotEmpty(); + Option hunkID = hunkIDs.find(id -> id.hunkID.equals(action.hunkIdentifier)); + assertThat(hunkID).isNotEmpty(); + hunksAdded.add(hunkID.get()); + })); + + interpreter.unsafeEvaluate(dbAction); + assertThat(hunksAdded) + .hasSameElementsAs(hunkIDs) + .hasSize(hunkIDs.size() * groupsWithHunks.size()); + } +} diff --git a/src/test/java/previewcode/backend/test/helpers/AnnotatedClassInstantiator.java b/src/test/java/previewcode/backend/test/helpers/AnnotatedClassInstantiator.java new file mode 100644 index 0000000..117e1bb --- /dev/null +++ b/src/test/java/previewcode/backend/test/helpers/AnnotatedClassInstantiator.java @@ -0,0 +1,88 @@ +package previewcode.backend.test.helpers; + +import io.vavr.control.Try; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +public class AnnotatedClassInstantiator { + + public Try instantiateAnnotationValue(Class annotation, Class annotatedClass) { + return getAnnotation(annotation, annotatedClass) + .flatMap(this::getAnnotationValue) + .flatMap(this::tryInstantiate); + } + + public Try getAnnotation(Class annotation, Class annotatedClass) { + if (annotatedClass.isAnnotationPresent(annotation)) { + return Try.success(annotatedClass.getAnnotation(annotation)); + } else { + return Try.failure(new RuntimeException(this.getClass().getSimpleName() + " can only be used via the " + annotation.getSimpleName() + " annotation.")); + } + } + + public Try> getAnnotationValue(Annotation annotation) { + try { + return Try.success((Class) annotation.annotationType().getMethod("value").invoke(annotation)); + } catch (IllegalAccessException e) { + return Try.failure(new RuntimeException(annotation.annotationType().getSimpleName() + ".value() must be public")); + } catch (NoSuchMethodException | InvocationTargetException e) { + return Try.failure(new RuntimeException(annotation.annotationType().getSimpleName() + " must have a `value` member")); + + } + } + + private Try tryInstantiate(Class moduleClass) { + try { + return Try.success(moduleClass.newInstance()); + } catch (InstantiationException e) { + return tryInstantiateInnerClass(moduleClass); + } catch (IllegalAccessException e) { + return tryFixAccessibility(moduleClass); + } + } + + private Try tryInstantiateInnerClass(Class moduleClass) { + Class enclosingClass = moduleClass.getEnclosingClass(); + if (enclosingClass != null) { + return tryInstantiate(enclosingClass) + .flatMap(parentInstance -> + tryGetConstructor(moduleClass, enclosingClass) + .flatMap(ctor -> { + ctor.setAccessible(true); + return tryInstantiateViaConstructor(moduleClass, ctor, parentInstance); + } )); + } + return Try.failure(new RuntimeException("Unable to instantiate " + moduleClass.getSimpleName() + ".\n" + + "Make sure the class is accessible and has a public no-args constructor")); + } + + private Try tryFixAccessibility(Class moduleClass) { + return tryGetConstructor(moduleClass).map(constructor -> { + constructor.setAccessible(true); + return constructor; + }).flatMap(constructor -> tryInstantiateViaConstructor(moduleClass, constructor)); + } + + private Try> tryGetConstructor(Class moduleClass, Class ... parameterTypes) { + try { + return Try.success(moduleClass.getConstructor(parameterTypes)); + } catch (NoSuchMethodException e) { + return Try.failure(new RuntimeException("Unable to instantiate " + moduleClass.getSimpleName() + ".\n" + + "Make sure the class has a no-args constructor")); + } + } + + private Try tryInstantiateViaConstructor(Class moduleClass, Constructor constructor, Object ... ctorArgs) { + try { + return Try.success(constructor.newInstance(ctorArgs)); + } catch (InstantiationException | IllegalAccessException e) { + return Try.failure(new RuntimeException("Unable to instantiate " + moduleClass.getSimpleName() + ".\n" + + "Make sure the class is accessible and has a public no-args constructor", e)); + } catch (InvocationTargetException e) { + return Try.failure(new RuntimeException("Unable to instantiate " + moduleClass.getSimpleName() + ". Exception in class constructor", e)); + } + } + +} diff --git a/src/test/java/previewcode/backend/test/helpers/ApiEndPointTest.java b/src/test/java/previewcode/backend/test/helpers/ApiEndPointTest.java new file mode 100644 index 0000000..cac63a3 --- /dev/null +++ b/src/test/java/previewcode/backend/test/helpers/ApiEndPointTest.java @@ -0,0 +1,35 @@ +package previewcode.backend.test.helpers; + +import com.google.inject.servlet.ServletModule; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Helper annotation for tests that wish to run a Jetty Embedded instance and call it's endpoints. + * + * Runs the {@link GuiceResteasyExtension} jUnit extension, which: + *
+ *
+ * - Starts a new Embedded Jetty instance once for before all tests + *
+ * - Uses the provided {@link ServletModule} to configure endpoint and dependency bindings. + *
+ * - Stops the server after all tests have been run. + *
+ * - Automatically inject a {@link javax.ws.rs.client.WebTarget} instance configured with the embedded server address/port: + *
+ * {@code
+ *      @Test
+ *      public void testApiIsReachable(WebTarget target) {
+ *          // WebTarget is automatically provided
+ *      }
+ * }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(GuiceResteasyExtension.class) +public @interface ApiEndPointTest { + Class value(); +} diff --git a/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java b/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java new file mode 100644 index 0000000..8393192 --- /dev/null +++ b/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java @@ -0,0 +1,81 @@ +package previewcode.backend.test.helpers; + +import org.jooq.DSLContext; +import org.jooq.SQLDialect; +import org.jooq.conf.Settings; +import org.jooq.impl.DSL; +import org.junit.jupiter.api.extension.*; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ExtensionContext.Store; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import previewcode.backend.database.model.DefaultCatalog; + +import java.sql.DriverManager; +import java.sql.SQLException; + +public class DatabaseTestExtension implements ParameterResolver, AfterEachCallback { + private static final Logger logger = LoggerFactory.getLogger(DatabaseTestExtension.class); + + private static final String userName = "admin"; + private static final String password = "password"; + private static final String url = "jdbc:postgresql://localhost:5432/preview_code"; + + @Override + public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + Class parameterType = parameterContext.getParameter().getType(); + return DSLContext.class.isAssignableFrom(parameterType); + } + + @Override + public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + Settings settings = new Settings() + .withExecuteLogging(true); + + logger.debug("Obtaining database connection from DriverManager..."); + try { + DSLContext dslContext = DSL.using(DriverManager.getConnection(url, userName, password), SQLDialect.POSTGRES_9_5, settings); + putDslContext(extensionContext, dslContext); + return dslContext; + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Override + public void afterEach(TestExtensionContext context) throws Exception { + DSLContext db = getDslContext(context); + if (db != null) { + logger.debug("Commence database cleanup."); + DefaultCatalog.DEFAULT_CATALOG.getSchemas().forEach(schema -> { + logger.debug("\tCleaning schema: " + schema.getName()); + schema.getTables().forEach(t -> { + logger.debug("\t\tTruncating table: " + t.getName()); + db.truncate(t).restartIdentity().cascade().execute(); + }); + schema.getSequences().forEach(s -> { + logger.debug("\t\tRestarting sequence: " + s.getName()); + db.alterSequence(s).restart().execute(); + }); + }); + + logger.debug("Database truncated."); + } + } + + private DSLContext getDslContext(ExtensionContext context) { + return getStore(context).get(getStoreKey(context), DSLContext.class); + } + + private void putDslContext(ExtensionContext context, DSLContext db) { + getStore(context).put(getStoreKey(context), db); + } + + private Object getStoreKey(ExtensionContext context) { + return context.getTestMethod().get(); + } + + private Store getStore(ExtensionContext context) { + return context.getStore(Namespace.create(getClass(), context)); + } +} diff --git a/src/test/java/previewcode/backend/test/helpers/DatabaseTests.java b/src/test/java/previewcode/backend/test/helpers/DatabaseTests.java new file mode 100644 index 0000000..8de62f4 --- /dev/null +++ b/src/test/java/previewcode/backend/test/helpers/DatabaseTests.java @@ -0,0 +1,32 @@ +package previewcode.backend.test.helpers; + +import org.junit.jupiter.api.extension.ExtendWith; +import previewcode.backend.database.model.DefaultCatalog; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Helper annotation for test classes that wish to interact with the local database. + * + * Runs the {@link DatabaseTestExtension} jUnit 5 extension, which: + *
+ *
+ * - Automatically injects DSLContext instances in test methods: + *
+ * {@code
+ *      @Test
+ *      public void test(DSLContext db) {
+ *          // DSLContext is automatically provided by the extension
+ *      }
+ *
+ * }
+ * + * - After each test, truncates all tables for the {@link DefaultCatalog} class. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ExtendWith(DatabaseTestExtension.class) +public @interface DatabaseTests { } \ No newline at end of file diff --git a/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java b/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java new file mode 100644 index 0000000..9bc3e2c --- /dev/null +++ b/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java @@ -0,0 +1,85 @@ +package previewcode.backend.test.helpers; + +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.servlet.GuiceFilter; +import com.google.inject.servlet.ServletModule; +import io.vavr.control.Try; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.jboss.resteasy.client.jaxrs.ResteasyClient; +import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; +import org.jboss.resteasy.plugins.guice.GuiceResteasyBootstrapServletContextListener; +import org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher; +import org.junit.jupiter.api.extension.*; + +import javax.ws.rs.client.WebTarget; + +public class GuiceResteasyExtension extends AnnotatedClassInstantiator implements BeforeAllCallback, AfterAllCallback, ParameterResolver { + + private Server server; + private ResteasyClient client; + + @Override + public void beforeAll(ContainerExtensionContext containerExtensionContext) throws Exception { + + ServletModule guiceModule = getServletModule(containerExtensionContext); + + client = new ResteasyClientBuilder().build(); + + server = new Server(); + ServerConnector httpConnector = new ServerConnector(server, new HttpConnectionFactory()); + httpConnector.setHost("localhost"); + httpConnector.setPort(0); + httpConnector.setIdleTimeout(5000); + server.addConnector(httpConnector); + + ServletContextHandler servletHandler = new ServletContextHandler(); + Injector injector = Guice.createInjector(guiceModule); + servletHandler.addEventListener(injector.getInstance(GuiceResteasyBootstrapServletContextListener.class)); + + ServletHolder sh = new ServletHolder(HttpServletDispatcher.class); + servletHandler.addFilter(new FilterHolder(injector.getInstance(GuiceFilter.class)), "/*", null); + servletHandler.addServlet(sh, "/*"); + + server.setHandler(servletHandler); + server.start(); + } + + private ServletModule getServletModule(ContainerExtensionContext context) { + return Try.of(() -> context.getTestClass().get()) + .flatMap(cls -> instantiateAnnotationValue(ApiEndPointTest.class, cls)) + .get(); + } + + private Try> getAnnotatedModule(Class testClass) { + if (testClass.isAnnotationPresent(ApiEndPointTest.class)) + return Try.success(testClass.getAnnotation(ApiEndPointTest.class).value()); + else + return Try.failure(new RuntimeException("GuiceResteasyExtension can only be used via the ApiEndPointTest annotation.")); + } + + + @Override + public void afterAll(ContainerExtensionContext containerExtensionContext) throws Exception { + if (client != null && server != null) { + client.close(); + server.stop(); + } + } + + @Override + public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return WebTarget.class.isAssignableFrom(parameterContext.getParameter().getType()); + } + + @Override + public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return client.target(server.getURI()); + } +} + From 9af6ccefe549a86e69097ec804a00d994b1af293 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Fri, 2 Jun 2017 10:05:20 +0200 Subject: [PATCH 02/22] Add tests for fetching groups --- .../backend/database/DatabaseInterpreter.java | 14 ++-- .../backend/database/PullRequestGroup.java | 5 ++ .../DatabaseInterpreter_GroupTest.java | 78 ++++++++++++------- .../DatabaseInterpreter_HunksTest.java | 7 +- 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/main/java/previewcode/backend/database/DatabaseInterpreter.java b/src/main/java/previewcode/backend/database/DatabaseInterpreter.java index d99a42b..98fb7e5 100644 --- a/src/main/java/previewcode/backend/database/DatabaseInterpreter.java +++ b/src/main/java/previewcode/backend/database/DatabaseInterpreter.java @@ -1,12 +1,10 @@ package previewcode.backend.database; import io.vavr.collection.List; -import org.jooq.DSLContext; -import org.jooq.Record1; +import org.jooq.*; import org.jooq.exception.DataAccessException; import org.postgresql.util.PSQLException; import previewcode.backend.services.actiondsl.Interpreter; -import previewcode.backend.services.actions.DatabaseActions; import javax.inject.Inject; @@ -16,6 +14,7 @@ public class DatabaseInterpreter extends Interpreter { private final DSLContext db; + private static final String UNIQUE_CONSTRAINT_VIOLATION = "23505"; @Inject public DatabaseInterpreter(DSLContext db) { @@ -36,7 +35,8 @@ protected PullRequestID insertPull(InsertPullIfNotExists action) { .fetchOne().getId() ); } catch (DataAccessException e) { - if (e.getCause() instanceof PSQLException && ((PSQLException) e.getCause()).getSQLState().equals("23505")) { + if (e.getCause() instanceof PSQLException && + ((PSQLException) e.getCause()).getSQLState().equals(UNIQUE_CONSTRAINT_VIOLATION)) { return this.fetchPullRequest(fetchPull(action.owner, action.name, action.number)); } else { throw e; @@ -68,7 +68,9 @@ protected GroupID insertNewGroup(NewGroup newGroup) { ); } - protected List fetchGroups(FetchGroupsForPull fetchGroupsForPull) { - return null; + protected List fetchGroups(FetchGroupsForPull action) { + return List.ofAll(db.selectFrom(GROUPS) + .where(GROUPS.PULL_REQUEST_ID.eq(action.pullRequestID.id)) + .fetch(PullRequestGroup::fromRecord)); } } diff --git a/src/main/java/previewcode/backend/database/PullRequestGroup.java b/src/main/java/previewcode/backend/database/PullRequestGroup.java index 5198483..3f00d25 100644 --- a/src/main/java/previewcode/backend/database/PullRequestGroup.java +++ b/src/main/java/previewcode/backend/database/PullRequestGroup.java @@ -1,6 +1,7 @@ package previewcode.backend.database; import io.vavr.collection.List; +import previewcode.backend.database.model.tables.records.GroupsRecord; import static previewcode.backend.services.actiondsl.ActionDSL.*; import static previewcode.backend.services.actions.DatabaseActions.*; @@ -39,6 +40,10 @@ public PullRequestGroup(GroupID id, String title, String description) { this.fetchHunks = fetchHunks(id); } + public static PullRequestGroup fromRecord(GroupsRecord record) { + return new PullRequestGroup(new GroupID(record.getId()), record.getTitle(), record.getDescription()); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java index d7fca2b..bd4a167 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java @@ -1,17 +1,18 @@ package previewcode.backend.database; +import io.vavr.collection.List; import org.jooq.DSLContext; -import org.jooq.Record3; import org.jooq.exception.DataAccessException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import previewcode.backend.database.model.tables.records.GroupsRecord; +import previewcode.backend.services.actiondsl.Interpreter; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.junit.Assert.fail; import static previewcode.backend.database.model.Tables.GROUPS; import static previewcode.backend.database.model.Tables.PULL_REQUEST; +import static previewcode.backend.services.actiondsl.ActionDSL.*; import static previewcode.backend.services.actions.DatabaseActions.*; @@ -22,22 +23,6 @@ public class DatabaseInterpreter_GroupTest extends DatabaseInterpreterTest { private static final PullRequestID dbPullId = new PullRequestID(42L); - // private List groups = List.of( -// new PullRequestGroup(new GroupID(42L), "Group A", "Description A"), -// new PullRequestGroup(new GroupID(24L), "Group B", "Description B") -// ); -// -// private List groupsWithoutHunks = groups.map(group -> -// new OrderingGroupWithID(group, Lists.newLinkedList()) -// ); -// -// private List hunkIDs = List.of( -// new HunkID("abcd"), new HunkID("efgh"), new HunkID("ijkl")); -// -// private List groupsWithHunks = groups.map(group -> -// new OrderingGroupWithID(group, hunkIDs.map(id -> id.hunkID).toJavaList()) -// ); - @BeforeEach @Override public void setup(DSLContext db) { @@ -59,7 +44,8 @@ public void newGroup_insertsGroup(DSLContext db) throws Exception { @Test public void newGroup_returnsNewId(DSLContext db) throws Exception { - db.insertInto(GROUPS, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + db.insertInto(GROUPS) + .columns(GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) .values(dbPullId.id, "A", "B") .values(dbPullId.id, "C", "D") .values(dbPullId.id, "E", "F") @@ -78,8 +64,7 @@ public void newGroup_returnsNewId(DSLContext db) throws Exception { @Test public void newGroup_canInsertDuplicates(DSLContext db) throws Exception { NewGroup create = newGroup(dbPullId, groupTitle, groupDescription); - - GroupID groupID = eval(create.then(create)); + eval(create.then(create)); Integer groupCount = db.selectCount().from(GROUPS).fetchOne().value1(); assertThat(groupCount).isEqualTo(2); @@ -106,17 +91,56 @@ public void newGroup_pullRequestMustExist() { @Test - public void fetchGroups_returnsAllGroups() { - fail(); + public void fetchGroups_returnsAllGroups(DSLContext db) throws Exception { + db.insertInto(PULL_REQUEST, PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(dbPullId.id+1, "xyz", "pqr", number) + .execute(); + + db.insertInto(GROUPS) + .columns(GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(dbPullId.id, "A", "B") + .values(dbPullId.id, "C", "D") + .values(dbPullId.id, "E", "F") + .values(dbPullId.id+1, "X", "Y") + .execute(); + + List fetchedTitles = eval(fetchGroups(dbPullId)).map(g -> g.title); + assertThat(fetchedTitles).containsOnly("A", "C", "E"); + } + + @Test + public void fetchGroups_invalidPull_returnsNoResults() throws Exception { + PullRequestID invalidID = new PullRequestID(-1L); + List groups = eval(fetchGroups(invalidID)); + assertThat(groups).isEmpty(); } @Test - public void fetchGroups_pullRequestMustExist() { - fail(); + public void fetchGroups_fetchesCorrectGroupData(DSLContext db) throws Exception { + db.insertInto(GROUPS) + .columns(GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(dbPullId.id, "A", "B") + .execute(); + + PullRequestGroup group = eval(fetchGroups(dbPullId)).get(0); + assertThat(group.title).isEqualTo("A"); + assertThat(group.description).isEqualTo("B"); } @Test - public void fetchGroups_fetchesCorrectGroupData() { - fail(); + public void fetchGroups_hasHunkFetchingAction(DSLContext db) throws Exception { + db.insertInto(GROUPS) + .columns(GROUPS.ID, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(1234L, dbPullId.id, "A", "B") + .execute(); + + Action> hunkFetchAction = eval(fetchGroups(dbPullId)).get(0).fetchHunks; + + Interpreter i = interpret() + .on(FetchHunksForGroup.class).stop( + action -> assertThat(action.groupID).isEqualTo(new GroupID(1234L))); + + assertThatExceptionOfType(Interpreter.StoppedException.class) + .isThrownBy(() -> i.unsafeEvaluate(hunkFetchAction)); } } diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java index 4bef7d4..bb78278 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java @@ -12,7 +12,12 @@ public void assignHunk_groupMustExist() { } @Test - public void assignHunk_cannotAssignTwice() { + public void assignHunk_cannotAssignTwice_toSameGroup() { + fail(); + } + + @Test + public void assignHunk_cannotAssignTwice_toDifferentGroups() { fail(); } } From 57e0f5a1a8f28d2d2a69b4c0ba1e4faf68f82262 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Fri, 2 Jun 2017 11:01:33 +0200 Subject: [PATCH 03/22] Add tests for assigning hunks --- .../backend/database/DatabaseInterpreter.java | 11 +++ .../database/DatabaseInterpreterTest.java | 5 +- .../DatabaseInterpreter_GroupTest.java | 2 - .../DatabaseInterpreter_HunksTest.java | 67 +++++++++++++++++-- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/main/java/previewcode/backend/database/DatabaseInterpreter.java b/src/main/java/previewcode/backend/database/DatabaseInterpreter.java index 98fb7e5..0c36555 100644 --- a/src/main/java/previewcode/backend/database/DatabaseInterpreter.java +++ b/src/main/java/previewcode/backend/database/DatabaseInterpreter.java @@ -1,5 +1,6 @@ package previewcode.backend.database; +import io.atlassian.fugue.Unit; import io.vavr.collection.List; import org.jooq.*; import org.jooq.exception.DataAccessException; @@ -9,6 +10,7 @@ import javax.inject.Inject; import static previewcode.backend.database.model.Tables.*; +import static previewcode.backend.services.actiondsl.ActionDSL.unit; import static previewcode.backend.services.actions.DatabaseActions.*; public class DatabaseInterpreter extends Interpreter { @@ -23,6 +25,15 @@ public DatabaseInterpreter(DSLContext db) { on(InsertPullIfNotExists.class).apply(this::insertPull); on(NewGroup.class).apply(this::insertNewGroup); on(FetchGroupsForPull.class).apply(this::fetchGroups); + on(AssignHunkToGroup.class).apply(this::assignHunk); + } + + protected Unit assignHunk(AssignHunkToGroup action) { + db.insertInto(HUNK) + .columns(HUNK.GROUP_ID, HUNK.ID) + .values(action.groupID.id, action.hunkIdentifier) + .execute(); + return unit; } diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java index 42a0cf1..3569e47 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java @@ -16,7 +16,10 @@ public class DatabaseInterpreterTest { protected static final String name = "backend"; protected static final Integer number = 42; - protected PullRequestIdentifier pullIdentifier = new PullRequestIdentifier(owner, name, number); + protected static final PullRequestIdentifier pullIdentifier = new PullRequestIdentifier(owner, name, number); + + protected static final PullRequestID dbPullId = new PullRequestID(42L); + @BeforeEach public void setup(DSLContext db) { diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java index bd4a167..7e65926 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java @@ -21,8 +21,6 @@ public class DatabaseInterpreter_GroupTest extends DatabaseInterpreterTest { private static final String groupTitle = "Title"; private static final String groupDescription = "Description"; - private static final PullRequestID dbPullId = new PullRequestID(42L); - @BeforeEach @Override public void setup(DSLContext db) { diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java index bb78278..eff60cf 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java @@ -1,23 +1,78 @@ package previewcode.backend.database; +import io.vavr.Tuple2; +import org.jooq.DSLContext; +import org.jooq.exception.DataAccessException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.junit.Assert.fail; +import static org.assertj.core.api.Assertions.*; +import static previewcode.backend.database.model.Tables.GROUPS; +import static previewcode.backend.database.model.Tables.HUNK; +import static previewcode.backend.database.model.Tables.PULL_REQUEST; +import static previewcode.backend.services.actions.DatabaseActions.*; public class DatabaseInterpreter_HunksTest extends DatabaseInterpreterTest { + private static final String hunkID = "ABCDEF"; + + private static final GroupID group_A_id = new GroupID(0L); + private static final GroupID group_B_id = new GroupID(1L); + + @BeforeEach + @Override + public void setup(DSLContext db) { + super.setup(db); + db.insertInto(PULL_REQUEST, PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(dbPullId.id, owner, name, number) + .execute(); + + db.insertInto(GROUPS) + .columns(GROUPS.ID, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(group_A_id.id, dbPullId.id, "A", "B") + .values(group_B_id.id, dbPullId.id, "C", "D") + .execute(); + } + @Test - public void assignHunk_groupMustExist() { - fail(); + public void assignHunk_groupMustExist() throws Exception { + GroupID invalidID = new GroupID(-1L); + assertThatExceptionOfType(DataAccessException.class) + .isThrownBy(() -> eval(assignToGroup(invalidID, hunkID))); } @Test public void assignHunk_cannotAssignTwice_toSameGroup() { - fail(); + AssignHunkToGroup assign = assignToGroup(group_A_id, hunkID); + + assertThatExceptionOfType(DataAccessException.class) + .isThrownBy(() -> eval(assign.then(assign))); + } + + @Test + public void assignHunk_insertsIntoHunkTable(DSLContext db) throws Exception { + eval(assignToGroup(group_A_id, hunkID)); + + assertThat( + db.selectCount().from(HUNK).fetchOneInto(Integer.class) + ).isOne(); + } + + @Test + public void assignHunk_canInsertDuplicates(DSLContext db) throws Exception { + eval(assignToGroup(group_A_id, hunkID).then(assignToGroup(group_B_id, hunkID))); + + assertThat( + db.selectCount().from(HUNK).fetchOneInto(Integer.class) + ).isEqualTo(2); } @Test - public void assignHunk_cannotAssignTwice_toDifferentGroups() { - fail(); + public void assignHunk_insertsCorrectData(DSLContext db) throws Exception { + eval(assignToGroup(group_A_id, hunkID)); + + Tuple2 record = db.select(HUNK.GROUP_ID, HUNK.ID).from(HUNK).fetchOneInto(Tuple2.class); + assertThat(record).isEqualTo(new Tuple2(group_A_id.id, hunkID)); } + } From 60a6d33281dd19b1db1af6de5c7cb212a8bd57f3 Mon Sep 17 00:00:00 2001 From: Eva Anker Date: Thu, 29 Jun 2017 11:43:29 +0200 Subject: [PATCH 04/22] [WIP] - Added initial version of approvingAPI v2 (#52) * Added initial version of approvingAPI v2 * Allow users to set the approval status multiple times * Implement getApprovals * Implement and test GitHub authentication actions (#53) * Add tests for getApprovals actions * Added two tests (then stepper was empty) * Test that evaluation of getApproval finishes * Added enpoint for fetching hunk approvals * Added tests for getHunkApprovals * Added javadoc for approvalsAPI * Change all occurrences of Seq to List * Implement DeleteGroup in DatabaseInterpreter * Ensure ApproveStatus is never null * Implement FetchHunksForGroup in DatabaseInterpreter * Make Approvals refer to a hunk by their ID * Constrain hunks to be unique per pull request * Coerce the jOOQ api into handling 'INSERT INTO ... RETURNING ...' queries * Change the database dsl to return entire hunks/approvals * Fix tests for database service * Re-implement hunk and approval fetching * Implement generic deserialization of types with one field * Implement wrapped type serialization * Test wrapped type de/serialization * Implement github authentication actions and a caching interpreter * Import hunks when a PR is opened (#54) --- pom.xml | 59 +++- .../java/previewcode/backend/APIModule.java | 10 +- .../java/previewcode/backend/DTO/Approve.java | 21 -- .../backend/DTO/ApproveRequest.java | 33 ++ .../backend/DTO/ApproveStatus.java | 34 ++ .../backend/DTO/ApprovedGroup.java | 31 ++ .../backend/DTO/ApprovedPullRequest.java | 31 ++ .../java/previewcode/backend/DTO/Diff.java | 38 ++ .../backend/DTO/GitHubUserToken.java | 21 ++ .../backend/DTO/HunkApprovals.java | 28 ++ .../previewcode/backend/DTO/HunkChecksum.java | 44 +++ .../backend/DTO/InstallationID.java | 45 +++ .../backend/DTO/InstallationToken.java | 17 + .../backend/DTO/OrderingGroup.java | 19 +- .../backend/DTO/OrderingGroupWithID.java | 2 +- .../java/previewcode/backend/DTO/User.java | 25 -- .../DTO/serializers/JsonToWrappedType.java | 41 +++ .../DTO/serializers/WrappedTypeToJson.java | 52 +++ .../backend/DTO/serializers/Wraps.java | 7 + .../java/previewcode/backend/MainModule.java | 55 +-- .../exceptionmapper/GitHubApiException.java | 9 +- .../GitHubApiExceptionMapper.java | 19 - .../api/exceptionmapper/HttpApiException.java | 16 + .../HttpApiExceptionMapper.java | 24 ++ .../api/exceptionmapper/NoTokenException.java | 8 + .../NoTokenExceptionMapper.java | 16 + .../NotAuthorizedException.java | 7 + .../NotAuthorizedExceptionMapper.java | 19 + .../api/filter/GitHubAccessTokenFilter.java | 215 +----------- .../backend/api/filter/IJWTTokenCreator.java | 10 + .../backend/api/filter/JWTTokenCreator.java | 36 ++ .../backend/api/v1/AssigneesAPI.java | 14 +- .../backend/api/v1/WebhookAPI.java | 33 +- .../backend/api/v2/ApprovalsAPI.java | 89 +++++ .../backend/api/v2/OrderingAPI.java | 15 +- .../backend/database/DatabaseInterpreter.java | 87 ----- .../previewcode/backend/database/Hunk.java | 53 +++ .../backend/database/HunkApproval.java | 35 ++ .../previewcode/backend/database/HunkID.java | 40 +-- .../backend/database/PullRequestGroup.java | 11 +- .../backend/services/DatabaseService.java | 107 +++++- .../backend/services/DiffParser.java | 81 +++++ .../backend/services/FirebaseService.java | 6 +- .../backend/services/GithubService.java | 67 +++- .../backend/services/IDatabaseService.java | 13 +- .../services/actiondsl/ActionCache.java | 136 ++++++++ .../backend/services/actiondsl/ActionDSL.java | 39 ++- .../services/actiondsl/ActionMain.java | 83 ----- .../actiondsl/CachingInterpreter.java | 58 ++++ .../services/actiondsl/Interpreter.java | 199 +++++++++-- .../services/actiondsl/WithSyntax.java | 153 ++++++++ .../services/actions/DatabaseActions.java | 130 ++++++- .../services/actions/GitHubActions.java | 150 +++++++- .../actions/RequestContextActions.java | 83 +++++ .../services/http/HttpRequestExecutor.java | 38 ++ .../services/http/IHttpRequestExecutor.java | 14 + .../interpreters/DatabaseInterpreter.java | 139 ++++++++ .../interpreters/GitHubAuthInterpreter.java | 164 +++++++++ .../RequestContextActionInterpreter.java | 63 ++++ .../db-migration/V1__initialise_database.sql | 23 +- .../previewcode/backend/DTO/DiffTest.java | 322 +++++++++++++++++ .../backend/api/v2/EndPointTest.java | 76 +++- .../RequestContextActionInterpreterTest.java | 106 ++++++ .../api/v2/WrapppedTypeDeserializingTest.java | 29 ++ .../database/DatabaseInterpreterTest.java | 3 +- .../DatabaseInterpreter_ApprovalTest.java | 112 ++++++ .../DatabaseInterpreter_GroupTest.java | 89 ++++- .../DatabaseInterpreter_HunksTest.java | 56 ++- .../DatabaseInterpreter_PullRequestTest.java | 12 +- .../backend/database/SchemaTest.java | 10 +- .../github/GitHubAuthInterpreterTest.java | 326 ++++++++++++++++++ .../backend/services/DatabaseServiceTest.java | 252 ++++++++++++-- .../backend/services/GitHubServiceTest.java | 141 ++++++++ .../actiondsl/CachingInterpreterTest.java | 152 ++++++++ .../test/helpers/DatabaseTestExtension.java | 25 +- .../test/helpers/GuiceResteasyExtension.java | 31 +- .../backend/test/helpers/TestStore.java | 30 ++ 77 files changed, 4109 insertions(+), 778 deletions(-) delete mode 100644 src/main/java/previewcode/backend/DTO/Approve.java create mode 100644 src/main/java/previewcode/backend/DTO/ApproveRequest.java create mode 100644 src/main/java/previewcode/backend/DTO/ApproveStatus.java create mode 100644 src/main/java/previewcode/backend/DTO/ApprovedGroup.java create mode 100644 src/main/java/previewcode/backend/DTO/ApprovedPullRequest.java create mode 100644 src/main/java/previewcode/backend/DTO/Diff.java create mode 100644 src/main/java/previewcode/backend/DTO/GitHubUserToken.java create mode 100644 src/main/java/previewcode/backend/DTO/HunkApprovals.java create mode 100644 src/main/java/previewcode/backend/DTO/HunkChecksum.java create mode 100644 src/main/java/previewcode/backend/DTO/InstallationID.java create mode 100644 src/main/java/previewcode/backend/DTO/InstallationToken.java delete mode 100644 src/main/java/previewcode/backend/DTO/User.java create mode 100644 src/main/java/previewcode/backend/DTO/serializers/JsonToWrappedType.java create mode 100644 src/main/java/previewcode/backend/DTO/serializers/WrappedTypeToJson.java create mode 100644 src/main/java/previewcode/backend/DTO/serializers/Wraps.java delete mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java create mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/HttpApiException.java create mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/HttpApiExceptionMapper.java create mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/NoTokenException.java create mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/NoTokenExceptionMapper.java create mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedException.java create mode 100644 src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedExceptionMapper.java create mode 100644 src/main/java/previewcode/backend/api/filter/IJWTTokenCreator.java create mode 100644 src/main/java/previewcode/backend/api/filter/JWTTokenCreator.java create mode 100644 src/main/java/previewcode/backend/api/v2/ApprovalsAPI.java delete mode 100644 src/main/java/previewcode/backend/database/DatabaseInterpreter.java create mode 100644 src/main/java/previewcode/backend/database/Hunk.java create mode 100644 src/main/java/previewcode/backend/database/HunkApproval.java create mode 100644 src/main/java/previewcode/backend/services/DiffParser.java create mode 100644 src/main/java/previewcode/backend/services/actiondsl/ActionCache.java delete mode 100644 src/main/java/previewcode/backend/services/actiondsl/ActionMain.java create mode 100644 src/main/java/previewcode/backend/services/actiondsl/CachingInterpreter.java create mode 100644 src/main/java/previewcode/backend/services/actiondsl/WithSyntax.java create mode 100644 src/main/java/previewcode/backend/services/actions/RequestContextActions.java create mode 100644 src/main/java/previewcode/backend/services/http/HttpRequestExecutor.java create mode 100644 src/main/java/previewcode/backend/services/http/IHttpRequestExecutor.java create mode 100644 src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java create mode 100644 src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java create mode 100644 src/main/java/previewcode/backend/services/interpreters/RequestContextActionInterpreter.java create mode 100644 src/test/java/previewcode/backend/DTO/DiffTest.java create mode 100644 src/test/java/previewcode/backend/api/v2/RequestContextActionInterpreterTest.java create mode 100644 src/test/java/previewcode/backend/api/v2/WrapppedTypeDeserializingTest.java create mode 100644 src/test/java/previewcode/backend/database/DatabaseInterpreter_ApprovalTest.java create mode 100644 src/test/java/previewcode/backend/github/GitHubAuthInterpreterTest.java create mode 100644 src/test/java/previewcode/backend/services/GitHubServiceTest.java create mode 100644 src/test/java/previewcode/backend/services/actiondsl/CachingInterpreterTest.java create mode 100644 src/test/java/previewcode/backend/test/helpers/TestStore.java diff --git a/pom.xml b/pom.xml index 7c22e26..4ad66fe 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,13 @@ ${resteasy.version} + + + org.jboss.resteasy + resteasy-client + ${resteasy.version} + + org.eclipse.jetty jetty-servlet @@ -73,6 +80,12 @@ ${jetty.version} + + + net.sourceforge.jregex + jregex + 1.2_01 + @@ -135,13 +148,19 @@ 0.8.0.RELEASE + + + com.github.ben-manes.caffeine + caffeine + 2.5.2 + - org.kohsuke - github-api - 1.76 + org.kohsuke + github-api + 1.76 @@ -247,17 +266,31 @@ - org.jboss.resteasy - resteasy-client - ${resteasy.version} + org.junit.platform + junit-platform-launcher + 1.0.0-M4 + + + + com.google.guava + guava-testlib + 22.0 + test + + + + org.springframework + spring-test + 4.3.9.RELEASE test + - - - - + + + + @@ -351,12 +384,6 @@ .* ${db.schema} - - - true - true - - previewcode.backend.database.model src/main/java diff --git a/src/main/java/previewcode/backend/APIModule.java b/src/main/java/previewcode/backend/APIModule.java index 96f6ba3..71c4526 100644 --- a/src/main/java/previewcode/backend/APIModule.java +++ b/src/main/java/previewcode/backend/APIModule.java @@ -8,9 +8,8 @@ import com.google.inject.servlet.ServletModule; import io.atlassian.fugue.Unit; import org.jboss.resteasy.plugins.guice.ext.JaxrsModule; -import previewcode.backend.api.exceptionmapper.GitHubApiExceptionMapper; -import previewcode.backend.api.exceptionmapper.IllegalArgumentExceptionMapper; -import previewcode.backend.api.exceptionmapper.RootExceptionMapper; +import previewcode.backend.api.exceptionmapper.*; +import previewcode.backend.api.v2.ApprovalsAPI; import previewcode.backend.api.v2.OrderingAPI; import previewcode.backend.api.v2.TestAPI; @@ -30,11 +29,14 @@ public void configureServlets() { // v2 this.bind(TestAPI.class); this.bind(OrderingAPI.class); + this.bind(ApprovalsAPI.class); // Exception mappers this.bind(RootExceptionMapper.class); this.bind(IllegalArgumentExceptionMapper.class); - this.bind(GitHubApiExceptionMapper.class); + this.bind(HttpApiExceptionMapper.class); + this.bind(NoTokenExceptionMapper.class); + this.bind(NotAuthorizedExceptionMapper.class); this.bind(JacksonObjectMapperProvider.class); } diff --git a/src/main/java/previewcode/backend/DTO/Approve.java b/src/main/java/previewcode/backend/DTO/Approve.java deleted file mode 100644 index b324975..0000000 --- a/src/main/java/previewcode/backend/DTO/Approve.java +++ /dev/null @@ -1,21 +0,0 @@ -package previewcode.backend.DTO; - -/** - * Information about approving hunks - */ -public class Approve { - /** - * The hunk which is approved - */ - public String hunkId; - /** - * If the hunk is approved or disapproved - */ - public boolean isApproved; - /** - * Which user approves this hunk - */ - public int githubLogin; - - -} diff --git a/src/main/java/previewcode/backend/DTO/ApproveRequest.java b/src/main/java/previewcode/backend/DTO/ApproveRequest.java new file mode 100644 index 0000000..3c30230 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/ApproveRequest.java @@ -0,0 +1,33 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Information about approving hunks + */ +public class ApproveRequest { + /** + * The hunk which is approved + */ + @JsonProperty("hunkChecksum") + public final String hunkChecksum; + /** + * If the hunk is approved or disapproved + */ + @JsonProperty("isApproved") + public final ApproveStatus isApproved; + /** + * Which user approves this hunk + */ + @JsonProperty("githubLogin") + public final String githubLogin; + + @JsonCreator + public ApproveRequest(@JsonProperty("hunkChecksum") String hunkChecksum, @JsonProperty("isApproved") + ApproveStatus isApproved, @JsonProperty("githubLogin") String githubLogin) { + this.hunkChecksum = hunkChecksum; + this.isApproved = isApproved; + this.githubLogin = githubLogin; + } +} diff --git a/src/main/java/previewcode/backend/DTO/ApproveStatus.java b/src/main/java/previewcode/backend/DTO/ApproveStatus.java new file mode 100644 index 0000000..55934b4 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/ApproveStatus.java @@ -0,0 +1,34 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Objects; + +public enum ApproveStatus { + APPROVED("approved"), + DISAPPROVED("disapproved"), + NONE("none"); + + private String approved; + + ApproveStatus(String approved) { + this.approved = approved; + } + + @JsonCreator + public static ApproveStatus fromString(String approved) { + Objects.requireNonNull(approved); + return ApproveStatus.valueOf(approved.toUpperCase()); + } + + @JsonValue + public String getApproved() { + return approved.toLowerCase(); + } + + @Override + public String toString() { + return approved; + } +} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/DTO/ApprovedGroup.java b/src/main/java/previewcode/backend/DTO/ApprovedGroup.java new file mode 100644 index 0000000..922cac4 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/ApprovedGroup.java @@ -0,0 +1,31 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonProperty; +import previewcode.backend.database.GroupID; + +import java.util.Map; + + +public class ApprovedGroup { + + /** + * If the group is approved + */ + @JsonProperty("approved") + public final ApproveStatus approved; + + /** + * All the hunks in this group + */ + @JsonProperty("hunks") + public final Map hunks; + + public final GroupID groupID; + + public ApprovedGroup(ApproveStatus approved, Map hunks, GroupID groupID) { + this.approved = approved; + this.hunks = hunks; + this.groupID = groupID; + } + +} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/DTO/ApprovedPullRequest.java b/src/main/java/previewcode/backend/DTO/ApprovedPullRequest.java new file mode 100644 index 0000000..961494a --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/ApprovedPullRequest.java @@ -0,0 +1,31 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +/** + * Information if a pull request or it's groups/hunks are approved + */ +public class ApprovedPullRequest { + /** + * If the pull request is approved + */ + @JsonProperty("approved") + public ApproveStatus approved; + + /** + * The groups of this pull request + */ + @JsonProperty("groups") + public Map groups; + + @JsonCreator + public ApprovedPullRequest(@JsonProperty("approved") ApproveStatus approved, @JsonProperty("groups") Map groups ) { + this.approved = approved; + this.groups = groups; + } + + +} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/DTO/Diff.java b/src/main/java/previewcode/backend/DTO/Diff.java new file mode 100644 index 0000000..8925c41 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/Diff.java @@ -0,0 +1,38 @@ +package previewcode.backend.DTO; + +import previewcode.backend.services.DiffParser; + +import java.util.List; + +/** + * Diff from GitHub, calculates hunkChecksums + */ +public class Diff { + /** + * The diff from GitHub; + */ + private final String diff; + /** + * The hunkChecksums for the diff. + */ + private final List hunkChecksums; + + /** + * @return the hunkChecksums + */ + public List getHunkChecksums() { + return hunkChecksums; + } + + /** + * Get diff and call parser + * @param diff from GitHub + */ + public Diff(String diff) { + this.diff = diff; + DiffParser parser = new DiffParser(); + hunkChecksums = parser.parseDiff(diff); + } + + +} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/DTO/GitHubUserToken.java b/src/main/java/previewcode/backend/DTO/GitHubUserToken.java new file mode 100644 index 0000000..aaf75af --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/GitHubUserToken.java @@ -0,0 +1,21 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown=true) +public class GitHubUserToken { + + @JsonProperty("token") + public final String token; + + @JsonCreator + public GitHubUserToken(@JsonProperty("token") String token) { + this.token = token; + } + + public static GitHubUserToken fromString(String token) { + return new GitHubUserToken(token); + } +} diff --git a/src/main/java/previewcode/backend/DTO/HunkApprovals.java b/src/main/java/previewcode/backend/DTO/HunkApprovals.java new file mode 100644 index 0000000..9e0817d --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/HunkApprovals.java @@ -0,0 +1,28 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +/** + * Information if a pull request or it's groups/hunks are approved + */ +public class HunkApprovals { + + + @JsonProperty("hunkID") + public String hunkChecksum; + + /** + * Per user the approvals status + */ + @JsonProperty("approvals") + public Map approvals; + + public HunkApprovals(HunkChecksum hunkChecksum, Map approvals) { + this.approvals = approvals; + this.hunkChecksum = hunkChecksum.checksum; + } + + +} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/DTO/HunkChecksum.java b/src/main/java/previewcode/backend/DTO/HunkChecksum.java new file mode 100644 index 0000000..e615880 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/HunkChecksum.java @@ -0,0 +1,44 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import previewcode.backend.DTO.serializers.JsonToWrappedType; +import previewcode.backend.DTO.serializers.WrappedTypeToJson; + +/** + * DTO representing a group of hunks as stored in the database. + */ + +@JsonDeserialize(converter = HunkChecksum.FromJson.class) +@JsonSerialize(converter = HunkChecksum.ToJson.class) +public class HunkChecksum { + static class FromJson extends JsonToWrappedType {} + static class ToJson extends WrappedTypeToJson {} + + public final String checksum; + + public HunkChecksum(String checksum) { + this.checksum = checksum; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + HunkChecksum hunkChecksum1 = (HunkChecksum) o; + + return checksum.equals(hunkChecksum1.checksum); + } + + @Override + public int hashCode() { + return checksum.hashCode(); + } + + @Override + public String toString() { + return "HunkChecksum{" + checksum + '}'; + } + +} diff --git a/src/main/java/previewcode/backend/DTO/InstallationID.java b/src/main/java/previewcode/backend/DTO/InstallationID.java new file mode 100644 index 0000000..388913a --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/InstallationID.java @@ -0,0 +1,45 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + + +@JsonIgnoreProperties(ignoreUnknown=true) +public class InstallationID { + + @JsonProperty("id") + public final String id; + + @JsonCreator + public InstallationID(@JsonProperty("id") String id) { + this.id = id; + } + + public static InstallationID fromJson(JsonNode json) { + return new InstallationID(json.get("installation").get("id").asText()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + InstallationID that = (InstallationID) o; + + return id.equals(that.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public String toString() { + return "InstallationID{" + + "id='" + id + '\'' + + '}'; + } +} diff --git a/src/main/java/previewcode/backend/DTO/InstallationToken.java b/src/main/java/previewcode/backend/DTO/InstallationToken.java new file mode 100644 index 0000000..ffdd046 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/InstallationToken.java @@ -0,0 +1,17 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown=true) +public class InstallationToken { + + @JsonProperty("token") + public final String token; + + @JsonCreator + public InstallationToken(@JsonProperty("token") String token) { + this.token = token; + } +} diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroup.java b/src/main/java/previewcode/backend/DTO/OrderingGroup.java index 676c1ec..0cf9bfa 100644 --- a/src/main/java/previewcode/backend/DTO/OrderingGroup.java +++ b/src/main/java/previewcode/backend/DTO/OrderingGroup.java @@ -16,7 +16,7 @@ public class OrderingGroup { * The list of diffs in the pul request */ @JsonProperty("diff") - public final List diff; + public final List hunkChecksums; /** * The body of the group @@ -27,14 +27,21 @@ public class OrderingGroup { @JsonCreator public OrderingGroup( - @JsonProperty("diff") List diff, + @JsonProperty("diff") List hunkChecksums, @JsonProperty("info") TitleDescription info) { - this.diff = diff; - this.info = info; + this.hunkChecksums = hunkChecksums; + String title = info.title; + if(title == null){ + title = ""; + } + this.info = new TitleDescription(title, info.description); } - public OrderingGroup(String title, String description, List hunks) { - this.diff = hunks; + public OrderingGroup(String title, String description, List hunks) { + this.hunkChecksums = hunks; + if(title == null){ + title = ""; + } this.info = new TitleDescription(title, description); } } diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java index a2d13f2..ba98db6 100644 --- a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java +++ b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java @@ -16,7 +16,7 @@ public class OrderingGroupWithID extends OrderingGroup { public final String id; - public OrderingGroupWithID(PullRequestGroup dbGroup, List hunkIds) { + public OrderingGroupWithID(PullRequestGroup dbGroup, List hunkIds) { super(dbGroup.title, dbGroup.description, hunkIds); this.id = dbGroup.id.id.toString(); } diff --git a/src/main/java/previewcode/backend/DTO/User.java b/src/main/java/previewcode/backend/DTO/User.java deleted file mode 100644 index 04e5d87..0000000 --- a/src/main/java/previewcode/backend/DTO/User.java +++ /dev/null @@ -1,25 +0,0 @@ -package previewcode.backend.DTO; - -import java.net.URL; - -/** - * The data of a user - * - * @author PReview-Code - * - */ -public class User { - /** - * The login name of the user - */ - public String login; - /** - * The url to the users homepage - */ - public URL html_url; - /** - * The url to the users avatar - */ - public String avatar_url; - -} diff --git a/src/main/java/previewcode/backend/DTO/serializers/JsonToWrappedType.java b/src/main/java/previewcode/backend/DTO/serializers/JsonToWrappedType.java new file mode 100644 index 0000000..dbb4a98 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/serializers/JsonToWrappedType.java @@ -0,0 +1,41 @@ +package previewcode.backend.DTO.serializers; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.Converter; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; + +public class JsonToWrappedType implements Converter { + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(new TypeReference(){}); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(new TypeReference(){}); + } + + @SuppressWarnings("unchecked") + @Override + public This convert(Wrapped value) { + String wrappedName = evilGetGenericType(0); + String destinationName = evilGetGenericType(1); + try { + Class wrappedClass = (Class) Class.forName(wrappedName); + Class aClass = (Class) Class.forName(destinationName); + return aClass.getConstructor(wrappedClass).newInstance(value); + + } catch (ClassNotFoundException | IllegalAccessException | NoSuchMethodException | InstantiationException | InvocationTargetException e) { + throw new RuntimeException("Cannot convert '" + value.toString() + "' from: " + wrappedName + " to: " + destinationName, e); + } + } + + private String evilGetGenericType(int i) { + return ((ParameterizedType) getClass() + .getGenericSuperclass()).getActualTypeArguments()[i].getTypeName(); + } +} diff --git a/src/main/java/previewcode/backend/DTO/serializers/WrappedTypeToJson.java b/src/main/java/previewcode/backend/DTO/serializers/WrappedTypeToJson.java new file mode 100644 index 0000000..10595e1 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/serializers/WrappedTypeToJson.java @@ -0,0 +1,52 @@ +package previewcode.backend.DTO.serializers; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.Converter; +import io.vavr.collection.List; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; + +public class WrappedTypeToJson implements Converter { + + @SuppressWarnings("unchecked") + @Override + public Wrapped convert(This value) { + try { + List fields = List.of(value.getClass().getDeclaredFields()); + List annotatedFields = fields.filter(f -> f.isAnnotationPresent(Wraps.class)); + Field field; + if (annotatedFields.size() > 1) { + throw new RuntimeException("Cannot convert wrapped type to json with multiple @Wrapped annotations"); + } else if (annotatedFields.size() == 0) { + if (fields.size() != 1) { + throw new RuntimeException("Only wrapped types with one field or one @Wraps annotated field can be converted"); + } else { + field = fields.get(); + } + } else { + field = annotatedFields.get(); + } + return (Wrapped) field.get(value); + } catch (IllegalAccessException e) { + throw new RuntimeException("Cannot access field for " + evilGetGenericType(0) + ". Make sure the field is public"); + } + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(new TypeReference(){}); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(new TypeReference(){}); + } + + private String evilGetGenericType(int i) { + return ((ParameterizedType) getClass() + .getGenericSuperclass()).getActualTypeArguments()[i].getTypeName(); + } +} diff --git a/src/main/java/previewcode/backend/DTO/serializers/Wraps.java b/src/main/java/previewcode/backend/DTO/serializers/Wraps.java new file mode 100644 index 0000000..8efdd28 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/serializers/Wraps.java @@ -0,0 +1,7 @@ +package previewcode.backend.DTO.serializers; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface Wraps {} diff --git a/src/main/java/previewcode/backend/MainModule.java b/src/main/java/previewcode/backend/MainModule.java index 5a72a51..9742739 100644 --- a/src/main/java/previewcode/backend/MainModule.java +++ b/src/main/java/previewcode/backend/MainModule.java @@ -7,6 +7,7 @@ import com.google.firebase.FirebaseOptions; import com.google.inject.Provides; import com.google.inject.name.Named; +import com.google.inject.name.Names; import com.google.inject.servlet.RequestScoped; import com.jolbox.bonecp.BoneCPDataSource; import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider; @@ -19,10 +20,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import previewcode.backend.api.filter.GitHubAccessTokenFilter; +import previewcode.backend.api.filter.IJWTTokenCreator; +import previewcode.backend.api.filter.JWTTokenCreator; import previewcode.backend.api.v1.*; +import previewcode.backend.services.interpreters.DatabaseInterpreter; +import previewcode.backend.services.interpreters.GitHubAuthInterpreter; +import previewcode.backend.services.http.HttpRequestExecutor; +import previewcode.backend.services.http.IHttpRequestExecutor; import previewcode.backend.services.DatabaseService; import previewcode.backend.services.GithubService; import previewcode.backend.services.IDatabaseService; +import previewcode.backend.services.actiondsl.ActionCache; +import previewcode.backend.services.actiondsl.Interpreter; import javax.crypto.spec.SecretKeySpec; import javax.sql.DataSource; @@ -66,6 +75,22 @@ public void configureServlets() { this.bind(IDatabaseService.class).to(DatabaseService.class); + try { + HttpRequestExecutor http = new HttpRequestExecutor(); + this.bind(IHttpRequestExecutor.class).toInstance(http); + } catch (IOException e) { + logger.error("Failed to instantiate HTTP Cache!", e); + System.exit(-1); + } + this.bind(IJWTTokenCreator.class).to(JWTTokenCreator.class); + + ActionCache.Builder b = new ActionCache.Builder(); + ActionCache cache = GitHubAuthInterpreter + .configure(b).maximumEntries(10000).build(); + + this.bind(ActionCache.class).toInstance(cache); + this.bind(Interpreter.class).annotatedWith(Names.named("database-interp")).to(DatabaseInterpreter.class); + initializeFireBase(); } @@ -103,9 +128,9 @@ private static DataSource initConnectionPool() { logger.info("Instantiating connection pool..."); BoneCPDataSource result = new BoneCPDataSource(); result.setDriverClass("org.postgresql.Driver"); - result.setJdbcUrl("jdbc:postgresql:library"); - result.setUsername("postgres"); - result.setPassword("test"); + result.setJdbcUrl("jdbc:postgresql://localhost:5432/preview_code"); + result.setUsername("admin"); + result.setPassword("password"); result.setDefaultAutoCommit(true); result.setPartitionCount(4); result.setMinConnectionsPerPartition(1); @@ -205,29 +230,7 @@ public GitHub provideGitHubConnection() { } /** - * Method to declare Named key "github.installation.token" to obtain the current GitHub Installation token - * @throws Exception if key was not set - */ - @Provides - @Named("github.installation.token") - @RequestScoped - public String provideGitHubInstallationToken() { - throw new NotAuthorizedException("Installation token must be received via an authorization call to the GitHub API."); - } - - /** - * Method to declare Named key "github.user.token" to obtain the current GitHub user OAuth token - * @throws Exception if token was not set - */ - @Provides - @Named("github.user.token") - @RequestScoped - public String provideGitHubUserToken() { - throw new NotAuthorizedException("User token must be received via request query parameter."); - } - - /** - * Method to declare Named key "github.token.builder" to ammend a OKHTTP Request with authorization info. + * Method to declare Named key "github.token.builder" to amend a OKHTTP Request with authorization info. * @throws Exception if not set via GitHubAccessTokenFilter. */ @Provides diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiException.java b/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiException.java index e8b616a..bbf56a1 100644 --- a/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiException.java +++ b/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiException.java @@ -1,11 +1,10 @@ package previewcode.backend.api.exceptionmapper; -public class GitHubApiException extends RuntimeException { +import okhttp3.HttpUrl; - public final Integer statusCode; +public class GitHubApiException extends HttpApiException { - public GitHubApiException(String message, Integer statusCode) { - super("Call to the GitHub API failed with message: " + message); - this.statusCode = statusCode; + public GitHubApiException(String message, Integer statusCode, HttpUrl url) { + super("Call to the GitHub API failed with message: " + message, statusCode, url); } } diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java deleted file mode 100644 index d06de49..0000000 --- a/src/main/java/previewcode/backend/api/exceptionmapper/GitHubApiExceptionMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package previewcode.backend.api.exceptionmapper; - -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.Provider; - -@Provider -public class GitHubApiExceptionMapper extends - AbstractExceptionMapper { - - @Override - public Response.Status getStatusCode(GitHubApiException exception) { - return Response.Status.fromStatusCode(exception.statusCode); - } - - @Override - protected String getExposedMessage(GitHubApiException exception) { - return "Error while calling GitHub API: " + exception.getMessage(); - } -} diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/HttpApiException.java b/src/main/java/previewcode/backend/api/exceptionmapper/HttpApiException.java new file mode 100644 index 0000000..45bd6b7 --- /dev/null +++ b/src/main/java/previewcode/backend/api/exceptionmapper/HttpApiException.java @@ -0,0 +1,16 @@ +package previewcode.backend.api.exceptionmapper; + +import okhttp3.HttpUrl; + +public class HttpApiException extends RuntimeException { + + public final Integer statusCode; + public final HttpUrl url; + + + public HttpApiException(String message, Integer statusCode, HttpUrl url) { + super(message); + this.statusCode = statusCode; + this.url = url; + } +} diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/HttpApiExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/HttpApiExceptionMapper.java new file mode 100644 index 0000000..0005ae6 --- /dev/null +++ b/src/main/java/previewcode/backend/api/exceptionmapper/HttpApiExceptionMapper.java @@ -0,0 +1,24 @@ +package previewcode.backend.api.exceptionmapper; + +import org.slf4j.LoggerFactory; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +@Provider +public class HttpApiExceptionMapper extends + AbstractExceptionMapper { + + private static final org.slf4j.Logger logger = LoggerFactory.getLogger(HttpApiExceptionMapper.class); + + @Override + public Response.Status getStatusCode(HttpApiException exception) { + return Response.Status.fromStatusCode(exception.statusCode); + } + + @Override + protected String getExposedMessage(HttpApiException exception) { + logger.error("Error while calling an external API: " + exception.url); + return exception.getMessage(); + } +} diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/NoTokenException.java b/src/main/java/previewcode/backend/api/exceptionmapper/NoTokenException.java new file mode 100644 index 0000000..acc4605 --- /dev/null +++ b/src/main/java/previewcode/backend/api/exceptionmapper/NoTokenException.java @@ -0,0 +1,8 @@ +package previewcode.backend.api.exceptionmapper; + +public class NoTokenException extends RuntimeException { + + public NoTokenException() { + super("API call requires an authentication token"); + } +} diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/NoTokenExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/NoTokenExceptionMapper.java new file mode 100644 index 0000000..79d709e --- /dev/null +++ b/src/main/java/previewcode/backend/api/exceptionmapper/NoTokenExceptionMapper.java @@ -0,0 +1,16 @@ +package previewcode.backend.api.exceptionmapper; + +import javax.ws.rs.core.Response; + +public class NoTokenExceptionMapper extends AbstractExceptionMapper { + + @Override + public Response.Status getStatusCode(NoTokenException exception) { + return Response.Status.UNAUTHORIZED; + } + + @Override + protected String getExposedMessage(NoTokenException exception) { + return exception.getMessage(); + } +} diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedException.java b/src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedException.java new file mode 100644 index 0000000..0976d17 --- /dev/null +++ b/src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedException.java @@ -0,0 +1,7 @@ +package previewcode.backend.api.exceptionmapper; + +public class NotAuthorizedException extends RuntimeException { + public NotAuthorizedException(String s) { + super(s); + } +} diff --git a/src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedExceptionMapper.java b/src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedExceptionMapper.java new file mode 100644 index 0000000..ed8c867 --- /dev/null +++ b/src/main/java/previewcode/backend/api/exceptionmapper/NotAuthorizedExceptionMapper.java @@ -0,0 +1,19 @@ +package previewcode.backend.api.exceptionmapper; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +@Provider +public class NotAuthorizedExceptionMapper extends + AbstractExceptionMapper { + + @Override + protected String getExposedMessage(NotAuthorizedException exception) { + return exception.getMessage(); + } + + @Override + protected Response.Status getStatusCode(NotAuthorizedException exception) { + return Response.Status.UNAUTHORIZED; + } +} diff --git a/src/main/java/previewcode/backend/api/filter/GitHubAccessTokenFilter.java b/src/main/java/previewcode/backend/api/filter/GitHubAccessTokenFilter.java index a668173..7b41fe4 100644 --- a/src/main/java/previewcode/backend/api/filter/GitHubAccessTokenFilter.java +++ b/src/main/java/previewcode/backend/api/filter/GitHubAccessTokenFilter.java @@ -1,225 +1,40 @@ package previewcode.backend.api.filter; -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.base.Strings; -import com.google.inject.Key; -import com.google.inject.name.Names; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import org.apache.commons.codec.binary.Hex; -import org.apache.commons.io.IOUtils; -import org.kohsuke.github.GitHub; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import previewcode.backend.api.exceptionmapper.GitHubApiException; +import org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext; +import previewcode.backend.services.interpreters.RequestContextActionInterpreter; +import previewcode.backend.services.interpreters.GitHubAuthInterpreter; import previewcode.backend.services.GithubService; +import previewcode.backend.services.actiondsl.Interpreter; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import javax.inject.Inject; -import javax.inject.Named; -import javax.ws.rs.NotAuthorizedException; +import javax.inject.Singleton; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerRequestFilter; import javax.ws.rs.container.PreMatching; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; import javax.ws.rs.ext.Provider; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.Calendar; -import java.util.Date; @Provider @PreMatching +@Singleton public class GitHubAccessTokenFilter implements ContainerRequestFilter { - private static final Logger logger = LoggerFactory.getLogger(GitHubAccessTokenFilter.class); - private static final ObjectMapper mapper = new ObjectMapper(); - private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient(); - - private static final String TOKEN_PARAMETER = "access_token"; - private static final String CURRENT_USER_NAME = "github.user"; - private static final String CURRENT_INSTALLATION_TOKEN = "github.installation.token"; - private static final String CURRENT_USER_TOKEN = "github.user.token"; - private static final String CURRENT_TOKEN_BUILDER = "github.token.builder"; - - private static final String GITHUB_WEBHOOK_USER_AGENT_PREFIX = "GitHub-Hookshot/"; - private static final String GITHUB_WEBHOOK_SECRET_HEADER = "X-Hub-Signature"; - - private static final Response UNAUTHORIZED = Response.status(Response.Status.UNAUTHORIZED).build(); - private static final RequestBody EMPTY_REQUEST_BODY = RequestBody.create(null, new byte[]{}); - @Inject - private Algorithm jwtSigningAlgorithm; + private GitHubAuthInterpreter gitHubAuthInterpreter; @Inject - @Named("github.webhook.secret") - private SecretKeySpec webhookSecret; - - @Inject - @Named("integration.id") - private String INTEGRATION_ID; + private GithubService.V2 authService; @Override - public void filter(ContainerRequestContext containerRequestContext) throws IOException { - checkForOAuthToken(containerRequestContext); - checkForWehbook(containerRequestContext); - } - - /** - * This method checks whether the request originates from a GitHub Webhook call. - * Is so, the call is verified against a shared webhook secret. - * When the call is verified to originate from GitHub, - * a request is made to receive a fresh Installation token. - * This token is then bound to `github.installation.token` and - * `github.token.builder` for usage with @Inject and @Named. - * - * Aborts the pending request with a 401 Unauthorized error if verification of the shared secret fails. - * - * @throws IOException when the token cannot be requested from GitHub - */ - private void checkForWehbook(ContainerRequestContext context) throws IOException { - String userAgent = context.getHeaderString("User-Agent"); - if (userAgent != null && userAgent.startsWith(GITHUB_WEBHOOK_USER_AGENT_PREFIX)) { - - String requestBody = readRequestBody(context); - - try { - verifyGitHubWebhookSecret(context, requestBody); - } catch (Exception e) { - logger.warn("Could not verify GitHub webhook call:", e); - context.abortWith(UNAUTHORIZED); - return; - } - String installationToken = getGitHubInstallationToken(requestBody); - logger.debug("Authenticated as Installation with: " + installationToken.hashCode()); - - context.setProperty(Key.get(String.class, Names.named(CURRENT_INSTALLATION_TOKEN)).toString(), installationToken); - GithubService.TokenBuilder builder = (Request.Builder request) -> - request - .header("Authorization", "token " + installationToken) - .addHeader("Accept", "application/vnd.github.machine-man-preview+json"); - - context.setProperty(Key.get(GithubService.TokenBuilder.class, Names.named(CURRENT_TOKEN_BUILDER)).toString(), builder); - } - } + public void filter(ContainerRequestContext ctx) throws IOException { + PreMatchContainerRequestContext context = (PreMatchContainerRequestContext) ctx; - /** - * Reads the request body and sets it back in the request to ensure the stream can still be read by API endpoints. - * @return The request body - * @throws IOException if the entity stream cannot be read. - */ - private String readRequestBody(ContainerRequestContext context) throws IOException { - String requestBody = IOUtils.toString(context.getEntityStream(), "UTF-8"); - context.setEntityStream(new ByteArrayInputStream(requestBody.getBytes(StandardCharsets.UTF_8))); - return requestBody; - } - - /** - * Verify that the incomming request originates from the GitHub webhook servers. - * - * @throws NoSuchAlgorithmException when HMAC SHA1 is not available. - * @throws InvalidKeyException if the webhook secret is inappropriate for initializing the MAC. - * @throws NotAuthorizedException when the received hash is invalid or missing. - */ - private void verifyGitHubWebhookSecret(ContainerRequestContext context, String requestBody) - throws NoSuchAlgorithmException, InvalidKeyException, NotAuthorizedException { - String receivedHash = context.getHeaderString(GITHUB_WEBHOOK_SECRET_HEADER); - if (receivedHash != null && receivedHash.startsWith("sha1=")) { - final Mac mac = Mac.getInstance("HmacSHA1"); - mac.init(webhookSecret); - final String expectedHash = Hex.encodeHexString(mac.doFinal(requestBody.getBytes())); - if (!receivedHash.equals("sha1="+ expectedHash)) { - throw new NotAuthorizedException("The received MAC does not match the configured MAC"); - } - } else { - throw new NotAuthorizedException("Expected to find an MAC hash in " + GITHUB_WEBHOOK_SECRET_HEADER); - } - } - - /** - * Authenticate against the GitHub Integrations API and fetch a token for the current Installation. - * - * @return A fresh authorization token for the installation making the current request. - * @throws IOException when the request body cannot be read or when the call to GitHub fails. - */ - private String getGitHubInstallationToken(String requestBody) throws IOException { - JsonNode body = mapper.readTree(requestBody); - String installationId = body.get("installation").get("id").asText(); - - Calendar calendar = Calendar.getInstance(); - Date now = calendar.getTime(); - calendar.add(Calendar.MINUTE, 10); - Date exp = calendar.getTime(); - - String token = JWT.create() - .withIssuedAt(now) - .withExpiresAt(exp) - .withIssuer(INTEGRATION_ID) - .sign(jwtSigningAlgorithm); - - logger.info("Authenticating installation {" + installationId + "} as integration {" + INTEGRATION_ID + "}"); - return this.authenticateInstallation(installationId, token); - } - - private String authenticateInstallation(String installationId, String integrationToken) throws IOException { - Request request = new Request.Builder() - .url("https://api.github.com/installations/" + installationId + "/access_tokens") - .addHeader("Accept", "application/vnd.github.machine-man-preview+json") - .addHeader("Authorization", "Bearer " + integrationToken) - .post(EMPTY_REQUEST_BODY) - .build(); - - logger.debug("[OKHTTP3] Executing request: " + request); - - try (okhttp3.Response response = OK_HTTP_CLIENT.newCall(request).execute()) { - String body = response.body().string(); - if (response.isSuccessful()) { - return mapper.readValue(body, JsonNode.class).get("token").asText(); - } else { - throw new GitHubApiException(body, response.code()); - } - } - } + gitHubAuthInterpreter.context = context; - /** - * Checks whether the `access_token` query parameter is present. - * This token is used to authenticate app users with OAuth. - * - * This method aborts the pending request with a 401 Unauthorized error - * if the token is invalid. - * - * If the token is valid, the token will be bound to `github.user.token`, - * the GitHub object will be bound to `github.user` and `github.token.builder`. - * These bindings can be used with Guice @Inject and @Named annotations. - * - * @throws IOException when unable to connect to GitHub with the provided token. - */ - private void checkForOAuthToken(ContainerRequestContext context) throws IOException { - final MultivaluedMap parameters = context.getUriInfo() - .getQueryParameters(); - final String token = parameters.getFirst(TOKEN_PARAMETER); + RequestContextActionInterpreter contextActionInterpreter = + new RequestContextActionInterpreter(context.getHttpRequest()); - if (!Strings.isNullOrEmpty(token)) { - try { - final GitHub user = GitHub.connectUsingOAuth(token); - context.setProperty(Key.get(GitHub.class, Names.named(CURRENT_USER_NAME)).toString(), user); - context.setProperty(Key.get(String.class, Names.named(CURRENT_USER_TOKEN)).toString(), token); - logger.debug("Authenticated as github user with: " + token.hashCode()); - GithubService.TokenBuilder builder = (Request.Builder b) -> b.header("Authorization", "token " + token); - context.setProperty(Key.get(GithubService.TokenBuilder.class, Names.named(CURRENT_TOKEN_BUILDER)).toString(), builder); - } catch (final NotAuthorizedException e) { - logger.warn("Could not connect to GitHub on behalf of user with OAuth:", e); - context.abortWith(UNAUTHORIZED); - } - } + new Interpreter(gitHubAuthInterpreter, contextActionInterpreter) + .unsafeEvaluate(authService.authenticate()); } } diff --git a/src/main/java/previewcode/backend/api/filter/IJWTTokenCreator.java b/src/main/java/previewcode/backend/api/filter/IJWTTokenCreator.java new file mode 100644 index 0000000..1deb72e --- /dev/null +++ b/src/main/java/previewcode/backend/api/filter/IJWTTokenCreator.java @@ -0,0 +1,10 @@ +package previewcode.backend.api.filter; + +/** + * Build a JWT Token from the integration ID. + * This interface is used in tests to stub the signing algorithm. + */ +@FunctionalInterface +public interface IJWTTokenCreator { + String create(String integrationId); +} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/api/filter/JWTTokenCreator.java b/src/main/java/previewcode/backend/api/filter/JWTTokenCreator.java new file mode 100644 index 0000000..0930808 --- /dev/null +++ b/src/main/java/previewcode/backend/api/filter/JWTTokenCreator.java @@ -0,0 +1,36 @@ +package previewcode.backend.api.filter; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; + +import javax.inject.Inject; +import java.util.Calendar; +import java.util.Date; + +/** + * Build a JWT Token from the integration ID. + * Used to authenticate GitHub installations. + */ +public class JWTTokenCreator implements IJWTTokenCreator { + + private final Algorithm jwtSigningAlgorithm; + + @Inject + public JWTTokenCreator(Algorithm jwtSigningAlgorithm) { + this.jwtSigningAlgorithm = jwtSigningAlgorithm; + } + + @Override + public String create(String integrationId) { + Calendar calendar = Calendar.getInstance(); + Date now = calendar.getTime(); + calendar.add(Calendar.MINUTE, 10); + Date exp = calendar.getTime(); + + return JWT.create() + .withIssuedAt(now) + .withExpiresAt(exp) + .withIssuer(integrationId) + .sign(jwtSigningAlgorithm); + } +} diff --git a/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java b/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java index b97c6e0..54013e6 100644 --- a/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java +++ b/src/main/java/previewcode/backend/api/v1/AssigneesAPI.java @@ -6,7 +6,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.core.MediaType; -import previewcode.backend.DTO.Approve; +import previewcode.backend.DTO.ApproveRequest; import previewcode.backend.services.FirebaseService; import com.google.inject.Inject; @@ -30,7 +30,7 @@ public class AssigneesAPI { /** * Creates a pull request - * + * * @param owner * The owner of the repository on which the pull request is created * @param name @@ -43,12 +43,12 @@ public class AssigneesAPI { */ @POST @Consumes(MediaType.APPLICATION_JSON) - public Approve setApprove(@PathParam("owner") String owner, - @PathParam("name") String name, - @PathParam("number") String number, - Approve body) throws IOException { + public ApproveRequest setApprove(@PathParam("owner") String owner, + @PathParam("name") String name, + @PathParam("number") String number, + ApproveRequest body) throws IOException { GHMyself user = githubService.getLoggedInUser(); - if (body.githubLogin != user.getId()) { + if (!body.githubLogin.equals(user.getLogin())) { throw new IllegalArgumentException("Can not set approve status of other user"); } firebaseService.setApproved(owner, name, number, body); diff --git a/src/main/java/previewcode/backend/api/v1/WebhookAPI.java b/src/main/java/previewcode/backend/api/v1/WebhookAPI.java index 404edcf..67dcb71 100644 --- a/src/main/java/previewcode/backend/api/v1/WebhookAPI.java +++ b/src/main/java/previewcode/backend/api/v1/WebhookAPI.java @@ -3,25 +3,22 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.atlassian.fugue.Unit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import previewcode.backend.DTO.GitHubPullRequest; -import previewcode.backend.DTO.GitHubRepository; -import previewcode.backend.DTO.OrderingStatus; -import previewcode.backend.DTO.PRComment; -import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.DTO.*; import previewcode.backend.services.FirebaseService; import previewcode.backend.services.GithubService; +import previewcode.backend.services.IDatabaseService; +import previewcode.backend.services.actiondsl.ActionDSL; +import previewcode.backend.services.actiondsl.Interpreter; +import previewcode.backend.services.interpreters.DatabaseInterpreter; import javax.inject.Inject; import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.core.Response; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; @Path("v1/webhook") public class WebhookAPI { @@ -34,18 +31,28 @@ public class WebhookAPI { private static final Response BAD_REQUEST = Response.status(Response.Status.BAD_REQUEST).build(); private static final Response OK = Response.ok().build(); + private final IDatabaseService databaseService; + private final Interpreter interpreter; + + @Inject private GithubService githubService; @Inject private FirebaseService firebaseService; + @Inject + public WebhookAPI(IDatabaseService databaseService, DatabaseInterpreter interpreter) { + this.databaseService = databaseService; + this.interpreter = interpreter; + } + @POST public Response onWebhookPost( String postData, @HeaderParam(GITHUB_WEBHOOK_EVENT_HEADER) String eventType, @HeaderParam(GITHUB_WEBHOOK_DELIVERY_HEADER) String delivery) - throws IOException, NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException { + throws Exception { logger.info("Receiving Webhook call {" + delivery + "} for event {" + eventType + "}"); @@ -65,6 +72,12 @@ public Response onWebhookPost( githubService.placePullRequestComment(repoAndPull.second, comment); githubService.setOrderingStatus(repoAndPull.second, pendingStatus); + //Add hunks to database + Diff diff = githubService.fetchDiff(repoAndPull.second); + OrderingGroup defaultGroup = new OrderingGroup("Default group", "Default group", diff.getHunkChecksums()); + ActionDSL.Action groupAction = databaseService.insertDefaultGroup(new PullRequestIdentifier(repoAndPull.first, repoAndPull.second), defaultGroup); + interpreter.evaluateToResponse(groupAction); + } else if (action.equals("synchronize")) { Pair repoAndPull = readRepoAndPullFromWebhook(body); OrderingStatus pendingStatus = new OrderingStatus(repoAndPull.second, repoAndPull.first); diff --git a/src/main/java/previewcode/backend/api/v2/ApprovalsAPI.java b/src/main/java/previewcode/backend/api/v2/ApprovalsAPI.java new file mode 100644 index 0000000..ba4f267 --- /dev/null +++ b/src/main/java/previewcode/backend/api/v2/ApprovalsAPI.java @@ -0,0 +1,89 @@ +package previewcode.backend.api.v2; + +import com.google.inject.Inject; +import io.atlassian.fugue.Unit; +import io.vavr.collection.List; +import previewcode.backend.DTO.*; +import previewcode.backend.services.IDatabaseService; +import previewcode.backend.services.actiondsl.Interpreter; + +import javax.inject.Named; +import javax.ws.rs.*; +import javax.ws.rs.core.Response; + +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +/** + * API for getting and setting the approvals on a pullrequest + */ +@Path("v2/{owner}/{name}/pulls/{number}/") +public class ApprovalsAPI { + + private Interpreter interpreter; + private IDatabaseService databaseService; + + @Inject + public ApprovalsAPI(@Named("database-interp") Interpreter interpreter, IDatabaseService databaseService) { + this.interpreter = interpreter; + this.databaseService = databaseService; + } + + /** + * Fetches all approvals and shows if pr/groups/hunks are (dis)approved + * @param owner The owner of the repository + * @param name The name of the repository + * @param number The pullrequest number + * @return if the pullrequest and the groups and hunks are disapproved or approved + */ + @Path("getApprovals") + @GET + public Response getApprovals(@PathParam("owner") String owner, + @PathParam("name") String name, + @PathParam("number") Integer number) { + PullRequestIdentifier pull = new PullRequestIdentifier(owner, name, number); + Action action = databaseService.getApproval(pull); + return interpreter.evaluateToResponse(action); + } + + /** + * Fetches all approvals and shows per hunk whom (dis)approved + * @param owner The owner of the repository + * @param name The name of the repository + * @param number The pullrequest number + * @return per hunk the approval status of the reviewers + */ + @Path("getHunkApprovals") + @GET + public Response getHunkApprovals(@PathParam("owner") String owner, + @PathParam("name") String name, + @PathParam("number") Integer number) { + PullRequestIdentifier pull = new PullRequestIdentifier(owner, name, number); + Action> action = databaseService.getHunkApprovals(pull); + + return interpreter.evaluateToResponse(action); + } + + /** + * Sets the approval from a user on a hunk. + * + * @param owner The owner of the repository + * @param name The name of the repository + * @param number The pullrequest number + * @param body Hunk approval information + * @return A unit response + */ + @Path("setApprove") + @POST + public Response setApprove(@PathParam("owner") String owner, + @PathParam("name") String name, + @PathParam("number") Integer number, + ApproveRequest body) { + + // TODO: check if user is a reviewer on this PR + + PullRequestIdentifier pull = new PullRequestIdentifier(owner, name, number); + Action action = databaseService.setApproval(pull, body); + + return interpreter.evaluateToResponse(action); + } +} diff --git a/src/main/java/previewcode/backend/api/v2/OrderingAPI.java b/src/main/java/previewcode/backend/api/v2/OrderingAPI.java index 2225d4c..1449a5c 100644 --- a/src/main/java/previewcode/backend/api/v2/OrderingAPI.java +++ b/src/main/java/previewcode/backend/api/v2/OrderingAPI.java @@ -1,17 +1,16 @@ package previewcode.backend.api.v2; +import com.google.inject.Inject; import io.atlassian.fugue.Unit; import io.vavr.collection.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import previewcode.backend.DTO.OrderingGroup; import previewcode.backend.DTO.PullRequestIdentifier; import previewcode.backend.services.IDatabaseService; -import static previewcode.backend.services.actiondsl.ActionDSL.*; - import previewcode.backend.services.actiondsl.Interpreter; -import javax.inject.Inject; +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +import javax.inject.Named; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -20,13 +19,11 @@ @Path("v2/{owner}/{name}/pulls/{number}/ordering") public class OrderingAPI { - private static final Logger logger = LoggerFactory.getLogger(OrderingAPI.class); - private final Interpreter interpreter; private final IDatabaseService databaseService; @Inject - public OrderingAPI(Interpreter interpreter, IDatabaseService databaseService) { + public OrderingAPI(@Named("database-interp") Interpreter interpreter, IDatabaseService databaseService) { this.interpreter = interpreter; this.databaseService = databaseService; } @@ -37,7 +34,7 @@ public Response updateOrdering( @PathParam("name") String name, @PathParam("number") Integer number, java.util.List body - ) throws Exception { + ){ PullRequestIdentifier pull = new PullRequestIdentifier(owner, name, number); Action action = databaseService.updateOrdering(pull, List.ofAll(body)); diff --git a/src/main/java/previewcode/backend/database/DatabaseInterpreter.java b/src/main/java/previewcode/backend/database/DatabaseInterpreter.java deleted file mode 100644 index 0c36555..0000000 --- a/src/main/java/previewcode/backend/database/DatabaseInterpreter.java +++ /dev/null @@ -1,87 +0,0 @@ -package previewcode.backend.database; - -import io.atlassian.fugue.Unit; -import io.vavr.collection.List; -import org.jooq.*; -import org.jooq.exception.DataAccessException; -import org.postgresql.util.PSQLException; -import previewcode.backend.services.actiondsl.Interpreter; - -import javax.inject.Inject; - -import static previewcode.backend.database.model.Tables.*; -import static previewcode.backend.services.actiondsl.ActionDSL.unit; -import static previewcode.backend.services.actions.DatabaseActions.*; - -public class DatabaseInterpreter extends Interpreter { - - private final DSLContext db; - private static final String UNIQUE_CONSTRAINT_VIOLATION = "23505"; - - @Inject - public DatabaseInterpreter(DSLContext db) { - this.db = db; - on(FetchPull.class).apply(this::fetchPullRequest); - on(InsertPullIfNotExists.class).apply(this::insertPull); - on(NewGroup.class).apply(this::insertNewGroup); - on(FetchGroupsForPull.class).apply(this::fetchGroups); - on(AssignHunkToGroup.class).apply(this::assignHunk); - } - - protected Unit assignHunk(AssignHunkToGroup action) { - db.insertInto(HUNK) - .columns(HUNK.GROUP_ID, HUNK.ID) - .values(action.groupID.id, action.hunkIdentifier) - .execute(); - return unit; - } - - - protected PullRequestID insertPull(InsertPullIfNotExists action) { - try { - return new PullRequestID( - db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) - .values(action.owner, action.name, action.number) - .returning(PULL_REQUEST.ID) - .fetchOne().getId() - ); - } catch (DataAccessException e) { - if (e.getCause() instanceof PSQLException && - ((PSQLException) e.getCause()).getSQLState().equals(UNIQUE_CONSTRAINT_VIOLATION)) { - return this.fetchPullRequest(fetchPull(action.owner, action.name, action.number)); - } else { - throw e; - } - } - } - - protected PullRequestID fetchPullRequest(FetchPull action) { - Record1 pullIdRecord = db.select(PULL_REQUEST.ID) - .from(PULL_REQUEST) - .where( PULL_REQUEST.OWNER.eq(action.owner) - .and(PULL_REQUEST.NAME.eq(action.name)) - .and(PULL_REQUEST.NUMBER.eq(action.number))) - .fetchAny(); - - if (pullIdRecord != null) { - return new PullRequestID(pullIdRecord.value1()); - } else { - throw new DatabaseException("Could not find pull request with identifier: " + - action.owner + "/" + action.name + "/" + action.number); - } - } - - protected GroupID insertNewGroup(NewGroup newGroup) { - return new GroupID( - db.insertInto(GROUPS, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) - .values(newGroup.pullRequestId.id, newGroup.title, newGroup.description) - .returning(GROUPS.ID).fetchOne().getId() - ); - } - - protected List fetchGroups(FetchGroupsForPull action) { - return List.ofAll(db.selectFrom(GROUPS) - .where(GROUPS.PULL_REQUEST_ID.eq(action.pullRequestID.id)) - .fetch(PullRequestGroup::fromRecord)); - } -} diff --git a/src/main/java/previewcode/backend/database/Hunk.java b/src/main/java/previewcode/backend/database/Hunk.java new file mode 100644 index 0000000..a2c7ca7 --- /dev/null +++ b/src/main/java/previewcode/backend/database/Hunk.java @@ -0,0 +1,53 @@ +package previewcode.backend.database; + +import io.vavr.collection.List; +import previewcode.backend.DTO.HunkChecksum; +import previewcode.backend.database.model.tables.records.HunkRecord; +import previewcode.backend.services.actiondsl.ActionDSL.Action; + +import static previewcode.backend.services.actions.DatabaseActions.fetchApprovals; + +public class Hunk { + public final HunkID id; + public final GroupID groupID; + public final HunkChecksum checksum; + public final Action> fetchApprovals; + + public Hunk(HunkID id, GroupID groupID, HunkChecksum checksum) { + this.id = id; + this.groupID = groupID; + this.checksum = checksum; + this.fetchApprovals = fetchApprovals(id); + } + + public static Hunk fromRecord(HunkRecord record) { + return new Hunk(new HunkID(record.getId()), new GroupID(record.getGroupId()), new HunkChecksum(record.getChecksum())); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Hunk hunk = (Hunk) o; + + return id.equals(hunk.id) && groupID.equals(hunk.groupID) && checksum.equals(hunk.checksum); + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + groupID.hashCode(); + result = 31 * result + checksum.hashCode(); + return result; + } + + @Override + public String toString() { + return "Hunk{" + + "id=" + id + + ", groupID=" + groupID + + ", checksum=" + checksum + + '}'; + } +} diff --git a/src/main/java/previewcode/backend/database/HunkApproval.java b/src/main/java/previewcode/backend/database/HunkApproval.java new file mode 100644 index 0000000..10b2512 --- /dev/null +++ b/src/main/java/previewcode/backend/database/HunkApproval.java @@ -0,0 +1,35 @@ +package previewcode.backend.database; + +import previewcode.backend.DTO.ApproveStatus; +import previewcode.backend.database.model.tables.records.ApprovalRecord; + +public class HunkApproval { + public final ApproveStatus approveStatus; + public final String approver; + + public HunkApproval(ApproveStatus approveStatus, String approver) { + this.approveStatus = approveStatus; + this.approver = approver; + } + + public static HunkApproval fromRecord(ApprovalRecord record) { + return new HunkApproval(ApproveStatus.fromString(record.getStatus()), record.getApprover()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + HunkApproval that = (HunkApproval) o; + + return approveStatus == that.approveStatus && approver.equals(that.approver); + } + + @Override + public int hashCode() { + int result = approveStatus.hashCode(); + result = 31 * result + approver.hashCode(); + return result; + } +} diff --git a/src/main/java/previewcode/backend/database/HunkID.java b/src/main/java/previewcode/backend/database/HunkID.java index 95bf97c..58470b7 100644 --- a/src/main/java/previewcode/backend/database/HunkID.java +++ b/src/main/java/previewcode/backend/database/HunkID.java @@ -1,41 +1,7 @@ package previewcode.backend.database; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * DTO representing a group of hunks as stored in the database. - */ -public class HunkID { - - @JsonProperty("hunkID") - public final String hunkID; - - - @JsonCreator - public HunkID(@JsonProperty("hunkID") String hunkID) { - this.hunkID = hunkID; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - HunkID hunkID1 = (HunkID) o; - - return hunkID.equals(hunkID1.hunkID); - } - - @Override - public int hashCode() { - return hunkID.hashCode(); - } - - @Override - public String toString() { - return "HunkID{" + - "hunkID='" + hunkID + '\'' + - '}'; +public class HunkID extends DatabaseID { + public HunkID(Long id) { + super(id); } } diff --git a/src/main/java/previewcode/backend/database/PullRequestGroup.java b/src/main/java/previewcode/backend/database/PullRequestGroup.java index 3f00d25..85457d9 100644 --- a/src/main/java/previewcode/backend/database/PullRequestGroup.java +++ b/src/main/java/previewcode/backend/database/PullRequestGroup.java @@ -26,22 +26,27 @@ public class PullRequestGroup { */ public final String description; + /** + * If the group is the default group or not + */ + public final Boolean defaultGroup; /** * Evaluating this action should result in the list of * hunk-ids of all hunks in this group. */ - public final Action> fetchHunks; + public final Action> fetchHunks; - public PullRequestGroup(GroupID id, String title, String description) { + public PullRequestGroup(GroupID id, String title, String description, Boolean defaultGroup) { this.id = id; this.title = title; this.description = description; this.fetchHunks = fetchHunks(id); + this.defaultGroup = defaultGroup; } public static PullRequestGroup fromRecord(GroupsRecord record) { - return new PullRequestGroup(new GroupID(record.getId()), record.getTitle(), record.getDescription()); + return new PullRequestGroup(new GroupID(record.getId()), record.getTitle(), record.getDescription(), record.getDefaultGroup()); } @Override diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 1fdafc0..7372c96 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -3,13 +3,12 @@ import io.atlassian.fugue.Unit; import io.vavr.collection.List; -import io.vavr.collection.Seq; -import previewcode.backend.DTO.OrderingGroup; -import previewcode.backend.DTO.PullRequestIdentifier; -import previewcode.backend.database.PullRequestGroup; -import previewcode.backend.database.PullRequestID; +import previewcode.backend.DTO.*; +import previewcode.backend.database.*; import previewcode.backend.services.actions.DatabaseActions; +import java.util.HashMap; +import java.util.Map; import java.util.function.Function; import static previewcode.backend.services.actiondsl.ActionDSL.*; import static previewcode.backend.services.actions.DatabaseActions.*; @@ -17,10 +16,22 @@ public class DatabaseService implements IDatabaseService { @Override - public Action updateOrdering(PullRequestIdentifier pull, Seq groups) { + public Action updateOrdering(PullRequestIdentifier pull, List groups) { return insertPullIfNotExists(pull) .then(this::clearExistingGroups) - .then(dbPullId -> traverse(groups, createGroup(dbPullId))).toUnit(); + .then(dbPullId -> traverse(groups, createGroup(dbPullId, false))).toUnit(); + } + + @Override + public Action insertDefaultGroup(PullRequestIdentifier pull, OrderingGroup group) { + return insertPullIfNotExists(pull) + .then(dbPullId -> createGroup(dbPullId, true).apply(group)).toUnit(); + } + + @Override + public Action setApproval(PullRequestIdentifier pull, ApproveRequest approve) { + return insertPullIfNotExists(pull).then(dbPullId -> + setApprove(dbPullId, approve.hunkChecksum, approve.githubLogin, approve.isApproved)); } @Override @@ -28,12 +39,24 @@ public Action> fetchPullRequestGroups(PullRequestIdentifi return fetchPull(pull).then(DatabaseActions::fetchGroups); } + @Override + public Action getApproval(PullRequestIdentifier pull) { + Function> toMap = approvedGroup -> { + Map map = new HashMap<>(); + map.put(approvedGroup.groupID.id, approvedGroup); + return map; + }; + return fetchPullRequestGroups(pull).then( + pullRequestGroups -> traverse(pullRequestGroups, group -> getGroupApproval(group.id)) + .map(approvals -> isPullApproved(pullRequestGroups.length(), combineMaps(approvals.map(toMap)))) + ); + } - public Function> createGroup(PullRequestID dbPullId) { + public Function> createGroup(PullRequestID dbPullId, Boolean defaultGroup) { return group -> - newGroup(dbPullId, group.info.title, group.info.description).then( - groupID -> traverse(List.ofAll(group.diff), hunkId -> assignToGroup(groupID, hunkId)) + newGroup(dbPullId, group.info.title, group.info.description, defaultGroup).then( + groupID -> traverse(List.ofAll(group.hunkChecksums), hunkId -> assignToGroup(groupID, hunkId.checksum)) ).toUnit(); } @@ -43,4 +66,68 @@ public Action clearExistingGroups(PullRequestID dbPullId) { .map(unit -> dbPullId); } + @Override + public Action> getHunkApprovals(PullRequestIdentifier pull) { + + return fetchPullRequestGroups(pull) + .then(traverse(group -> fetchHunks(group.id))) + .map(flatten()) + .then(traverse(hunk -> hunk.fetchApprovals.map(approvals -> { + Map m = new HashMap<>(); + approvals.forEach(approval -> m.put(approval.approver, approval.approveStatus)); + return new HunkApprovals(hunk.checksum, m); + }))); + } + + private static Action getGroupApproval(GroupID groupID) { + return fetchHunks(groupID).then( + hunks -> traverse(hunks, (Hunk h) -> h.fetchApprovals.map(approvals -> { + Map map = new HashMap<>(); + map.put(h.checksum.checksum, isHunkApproved(approvals.map(a -> a.approveStatus))); + return map; + })) + .map(DatabaseService::combineMaps) + .map(approvals -> new ApprovedGroup( + isGroupApproved(hunks.length(), approvals), + approvals, + groupID) + ) + ); + } + + private static Map combineMaps(List> maps) { + return maps.fold(new HashMap<>(), (a, b) -> { + a.putAll(b); + return a; + }); + } + + private static ApprovedPullRequest isPullApproved(Integer count, Map groups) { + Map map = new HashMap<>(); + groups.forEach((a, approvedGroup) -> map.put(a, approvedGroup.approved)); + return new ApprovedPullRequest(isGroupApproved(count, map), groups); + } + + private static
ApproveStatus isGroupApproved(Integer count, Map statuses) { + if (statuses.containsValue(ApproveStatus.DISAPPROVED)) { + return ApproveStatus.DISAPPROVED; + } else if (statuses.containsValue(ApproveStatus.NONE)) { + return ApproveStatus.NONE; + } else if (count.equals(statuses.size())) { + return ApproveStatus.APPROVED; + } else { + return ApproveStatus.NONE; + } + } + + private static ApproveStatus isHunkApproved(List statuses) { + if (statuses.contains(ApproveStatus.DISAPPROVED)) { + return ApproveStatus.DISAPPROVED; + } else if (statuses.contains(ApproveStatus.APPROVED)) { + return ApproveStatus.APPROVED; + } else { + return ApproveStatus.NONE; + } + } + } diff --git a/src/main/java/previewcode/backend/services/DiffParser.java b/src/main/java/previewcode/backend/services/DiffParser.java new file mode 100644 index 0000000..96a4d97 --- /dev/null +++ b/src/main/java/previewcode/backend/services/DiffParser.java @@ -0,0 +1,81 @@ +package previewcode.backend.services; + + +import jregex.Matcher; +import jregex.Pattern; +import org.apache.commons.codec.binary.Base64; +import previewcode.backend.DTO.HunkChecksum; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parser for GitHub diffs + */ +public class DiffParser { + + /** + * The diffparser for diffs + * @param diff to parse + * @return list of hunkChecksums + */ + public List parseDiff(String diff) { + ArrayList hunkChecksums = new ArrayList<>(); + + if (diff != "") { + String ANYTHING = "(?:.|\n)+?"; + String DIFF_DELIMETER = "diff --git"; + String DIFF_HEADER = DIFF_DELIMETER + ANYTHING; + String FROM_FILE = "(a\\/(?:.+))"; + String TO_FILE = "(b\\/(?:.+)|\\/dev\\/null)"; + String DIFF_FILES = "\\-\\-\\- (a\\/(?:.+)|\\/dev\\/null)\n" + + "\\+\\+\\+ " + TO_FILE + "\n"; + String DIFF_EXPRESSION = "(?:" + DIFF_HEADER + FROM_FILE + " " + TO_FILE + ANYTHING + "(" + DIFF_FILES + "(" + ANYTHING + "))?(?:(?=\n" + DIFF_DELIMETER + ")|$))"; + + String DIGITS = "(\\d+)(?:,(\\d+))?"; + String HUNK_DELIMETER = "@@ \\-" + DIGITS + " \\+" + DIGITS + " @@"; + String HUNK_EXPRESSION = "(" + HUNK_DELIMETER + ").*\n(" + ANYTHING + ")" + "?(?:(?=" + "\n" + HUNK_DELIMETER + ")|$)"; + + Matcher matchDiff = new Pattern(DIFF_EXPRESSION).matcher(diff); + + while (matchDiff.find()) { + String hunk = matchDiff.group(6); + + String fileName = getFileName(matchDiff.group(1), matchDiff.group(2)); + //There is no code in the hunk + if (hunk == null) { + //Done to be compatible with the frontend + String toEncode = fileName + ",undefined,undefined"; + hunkChecksums.add(new HunkChecksum(new String(Base64.encodeBase64(toEncode.getBytes())))); + } else { + + Matcher matchHunk = new Pattern(HUNK_EXPRESSION).matcher(hunk); + while (matchHunk.find()) { + ArrayList hunkList = new ArrayList<>(); + for (int i = 0; i < matchHunk.groupCount(); i++) { + String newFind = matchHunk.group(i); + hunkList.add(newFind); + } + String toEncode = fileName + "," + hunkList.get(2) + "," + hunkList.get(4); + hunkChecksums.add(new HunkChecksum(new String(Base64.encodeBase64(toEncode.getBytes())))); + } + } + } + } + return hunkChecksums; + } + + /** + * Gets the filename for file + * @param fromFile the previous name of the file + * @param toFile the current name of the file + * @return the filename + */ + private String getFileName(String fromFile, String toFile) { + if (toFile.equals("/dev/null")) { + return fromFile.substring(2); + } else { + return toFile.substring(2); + } + } +} diff --git a/src/main/java/previewcode/backend/services/FirebaseService.java b/src/main/java/previewcode/backend/services/FirebaseService.java index ac5a57b..77e054e 100644 --- a/src/main/java/previewcode/backend/services/FirebaseService.java +++ b/src/main/java/previewcode/backend/services/FirebaseService.java @@ -6,7 +6,7 @@ import com.google.inject.Singleton; import org.slf4j.LoggerFactory; import org.slf4j.Logger; -import previewcode.backend.DTO.Approve; +import previewcode.backend.DTO.ApproveRequest; import previewcode.backend.DTO.OrderingGroup; import previewcode.backend.DTO.PullRequestIdentifier; import previewcode.backend.DTO.Track; @@ -52,9 +52,9 @@ public FirebaseService() { * @param LGTM * The approved object with information for firebase */ - public void setApproved(String owner, String name, String number, Approve LGTM) { + public void setApproved(String owner, String name, String number, ApproveRequest LGTM) { this.ref.child(owner).child(name).child("pulls").child(number) - .child("hunkApprovals").child(LGTM.hunkId).child(String.valueOf(LGTM.githubLogin)).setValue(LGTM.isApproved); + .child("hunkApprovals").child(LGTM.hunkChecksum).child(String.valueOf(LGTM.githubLogin)).setValue(LGTM.isApproved); } /** diff --git a/src/main/java/previewcode/backend/services/GithubService.java b/src/main/java/previewcode/backend/services/GithubService.java index 5057c3f..aebde7c 100644 --- a/src/main/java/previewcode/backend/services/GithubService.java +++ b/src/main/java/previewcode/backend/services/GithubService.java @@ -8,6 +8,7 @@ import com.google.inject.Provider; import com.google.inject.name.Named; import com.google.inject.servlet.RequestScoped; +import io.atlassian.fugue.Unit; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -21,33 +22,56 @@ import org.kohsuke.github.GitHub; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import previewcode.backend.DTO.GitHubPullRequest; -import previewcode.backend.DTO.GitHubStatus; -import previewcode.backend.DTO.OrderingStatus; -import previewcode.backend.DTO.PRComment; -import previewcode.backend.DTO.PRLineComment; -import previewcode.backend.DTO.PRbody; -import previewcode.backend.DTO.PrNumber; -import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.DTO.*; import previewcode.backend.api.exceptionmapper.GitHubApiException; +import previewcode.backend.api.exceptionmapper.NoTokenException; +import previewcode.backend.services.actiondsl.ActionDSL.Action; import java.io.IOException; import java.util.List; import java.util.Optional; +import static previewcode.backend.services.actions.GitHubActions.*; +import static previewcode.backend.services.actiondsl.ActionDSL.*; +import static previewcode.backend.services.actions.RequestContextActions.*; + /** - * An abstract class that connects with github - * + * An abstract class that connects with GitHub */ @RequestScoped public class GithubService { + public static class V2 { + + private static final String GITHUB_WEBHOOK_SECRET_HEADER = "X-Hub-Signature"; + private static final String TOKEN_PARAMETER = "access_token"; + + public Action authenticate() { + return getUserAgent.then(isWebHookUserAgent).then(isWebHook -> { + if (isWebHook) { + return with(getRequestBody) + .and(getHeader(GITHUB_WEBHOOK_SECRET_HEADER)) + .then(verifyWebHookSecret) + .then(getJsonBody) + .map(InstallationID::fromJson) + .then(authenticateInstallation); + } else { + return getQueryParam(TOKEN_PARAMETER) + .map(o -> o.getOrElseThrow(NoTokenException::new)) + .map(GitHubUserToken::fromString) + .then(getUser) + .toUnit(); + } + }); + } + + + } + private static final Logger logger = LoggerFactory.getLogger(GithubService.class); private static final OkHttpClient OK_HTTP_CLIENT = new OkHttpClient(); private static final ObjectMapper mapper = new ObjectMapper(); - - @Inject @Named("github.token.builder") private TokenBuilder tokenBuilder; @@ -62,12 +86,9 @@ public class GithubService { * * @param gitHubProvider * The provider for GitHub data - * @throws IOException - * */ @Inject - protected GithubService(@Named("github.user") final Provider gitHubProvider) - throws IOException { + protected GithubService(@Named("github.user") final Provider gitHubProvider) { githubProvider = gitHubProvider; } @@ -211,6 +232,18 @@ public GitHubPullRequest fetchPullRequest(PullRequestIdentifier identifier) thro return fromJson(response, GitHubPullRequest.class); } + public Diff fetchDiff(GitHubPullRequest pullRequest) throws IOException { + logger.info("Fetching diff from GitHub API..."); + + Request getDiff = tokenBuilder.addToken(new Request.Builder()) + .url(pullRequest.links.self) + .addHeader("Accept", "application/vnd.github.VERSION.diff") + .get() + .build(); + String response = this.execute(getDiff); + return new Diff(response); + } + /** * Sends a request to GitHub to place a comment at the given pull request. @@ -284,7 +317,7 @@ private String execute(Request request) throws IOException, GitHubApiException { if (response.isSuccessful()) { return body; } else { - throw new GitHubApiException(body, response.code()); + throw new GitHubApiException(body, response.code(), request.url()); } } } diff --git a/src/main/java/previewcode/backend/services/IDatabaseService.java b/src/main/java/previewcode/backend/services/IDatabaseService.java index 0c4324b..e4a86d1 100644 --- a/src/main/java/previewcode/backend/services/IDatabaseService.java +++ b/src/main/java/previewcode/backend/services/IDatabaseService.java @@ -3,14 +3,21 @@ import io.atlassian.fugue.Unit; import io.vavr.collection.List; import io.vavr.collection.Seq; -import previewcode.backend.DTO.OrderingGroup; -import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.DTO.*; import previewcode.backend.database.PullRequestGroup; import static previewcode.backend.services.actiondsl.ActionDSL.*; public interface IDatabaseService { - Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, Seq body); + Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, List body); + + Action insertDefaultGroup(PullRequestIdentifier pullRequestIdentifier, OrderingGroup body); + + Action setApproval(PullRequestIdentifier pullRequestIdentifier, ApproveRequest approval); Action> fetchPullRequestGroups(PullRequestIdentifier pull); + + Action getApproval(PullRequestIdentifier pull); + + Action> getHunkApprovals(PullRequestIdentifier pull); } diff --git a/src/main/java/previewcode/backend/services/actiondsl/ActionCache.java b/src/main/java/previewcode/backend/services/actiondsl/ActionCache.java new file mode 100644 index 0000000..1fad053 --- /dev/null +++ b/src/main/java/previewcode/backend/services/actiondsl/ActionCache.java @@ -0,0 +1,136 @@ +package previewcode.backend.services.actiondsl; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import previewcode.backend.services.actiondsl.ActionDSL.Action; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * A cache for Action results. + * Use {@link ActionCache.Builder} to create and configure a cache + */ +public class ActionCache { + private final Cache, Object> cache; + private final Map, Long> expireTimes; + + protected ActionCache(Cache, Object> cache, Map, Long> expireTimes) { + this.cache = cache; + this.expireTimes = expireTimes; + } + + @SuppressWarnings("unchecked") + public > A get(X action, Function runAction) { + return (A) cache.get(action, __ -> runAction.apply(action)); + } + + protected Cache, Object> getCache() { + return cache; + } + + public Map, Long> getExpireTimes() { + return expireTimes; + } + + + public static class Builder { + private final Map, Long> expireTimes; + + public static class ExpireStep, X> { + private final Class actionClass; + private final Builder currentBuilder; + + private ExpireStep(Class actionClass, Builder currentBuilder) { + this.actionClass = actionClass; + this.currentBuilder = currentBuilder; + } + + public Builder afterWrite(long time, TimeUnit unit) { + Map, Long> expireTimes = new HashMap<>(); + expireTimes.putAll(currentBuilder.expireTimes); + expireTimes.put(actionClass, unit.toNanos(time)); + return new Builder(expireTimes); + } + } + + public static class FinalStep { + private final Builder currentBuilder; + private final long maxEntries; + private final Caffeine baseConfig; + + + public FinalStep(Builder currentBuilder, long maxEntries, Caffeine baseConfig) { + this.currentBuilder = currentBuilder; + this.maxEntries = maxEntries; + this.baseConfig = baseConfig; + } + + public FinalStep fromCaffeineConfig(Caffeine newConfig) { + return new FinalStep(currentBuilder, maxEntries, newConfig); + } + + public ActionCache build() { + Caffeine caffeine = baseConfig != null ? baseConfig : Caffeine.newBuilder(); + + if (this.maxEntries < Long.MAX_VALUE) { + caffeine = caffeine.maximumSize(this.maxEntries); + } + if (currentBuilder.expireTimes.size() > 0) { + caffeine = caffeine.expireAfter(new Expiry() { + @Override + public long expireAfterCreate(@Nonnull Object action, @Nonnull Object result, long currentTime) { + return expireDuration(action); + } + + @Override + public long expireAfterUpdate(@Nonnull Object action, @Nonnull Object result, long currentTime, long currentDuration) { + long duration = expireDuration(action); + return duration > 0 ? duration : currentDuration; + } + + private long expireDuration(@Nonnull Object action) { + Long aLong = currentBuilder.expireTimes.get(action.getClass()); + if (aLong != null) { + return aLong; + } else { + return 0; + } + } + + @Override + public long expireAfterRead(@Nonnull Object action, @Nonnull Object result, long currentTime, long currentDuration) { + return Long.MAX_VALUE; + } + + }); + } + return new ActionCache(caffeine.build(), currentBuilder.expireTimes); + } + } + + public Builder() { + this(new HashMap<>()); + } + + public Builder(Builder fromBuilder) { + this(fromBuilder.expireTimes); + } + + private Builder(Map, Long> expireTimes) { + this.expireTimes = expireTimes; + } + + public FinalStep maximumEntries(long maxSize) { + return new Builder.FinalStep(this, maxSize, null); + } + + public , X> ExpireStep expire(Class actionClass) { + return new ExpireStep<>(actionClass, this); + } + } +} diff --git a/src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java b/src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java index c291ac9..a2f42ad 100644 --- a/src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java +++ b/src/main/java/previewcode/backend/services/actiondsl/ActionDSL.java @@ -1,8 +1,11 @@ package previewcode.backend.services.actiondsl; import io.atlassian.fugue.Unit; +import io.vavr.CheckedFunction1; import io.vavr.collection.List; -import io.vavr.collection.Seq; +import io.vavr.collection.List; +import previewcode.backend.services.actiondsl.WithSyntax.*; + import java.util.function.Consumer; import java.util.function.Function; @@ -62,9 +65,9 @@ public Action map(Function f) { * {@code ap} is quite a 'low level' operator, so most of the time it will * be more convenient to use one of the following (which use {@code ap} internally): *
- * {@link ActionDSL#sequence(Seq)},
+ * {@link ActionDSL#sequence(List)},
* {@link ActionDSL#traverse(Function)} or
- * {@link ActionDSL#traverse(Seq, Function)} + * {@link ActionDSL#traverse(List, Function)} *
*
* Example: @@ -166,11 +169,6 @@ public Action map(Function f) { public Action ap(Action> f) { return new Apply<>(this.f.ap(f.map(fBC -> fBC::compose)), action); } - - @Override - public Action then(Function> f) { - return new Suspend<>(f, action.then(x -> this.f.map(fx -> fx.apply(x)))); - } } public static class Suspend extends Action
{ @@ -207,6 +205,14 @@ public static Action pure(A value) { return new Return<>(value); } + public static Function identity() { + return a -> a; + } + + public static Function>, List> flatten() { + return lists -> lists.flatMap(a -> a); + } + /** * Take a sequence of actions and turn them into a single action. * @@ -214,8 +220,8 @@ public static Action pure(A value) { * @param The type of each action * @return A single action that returns if all sequenced actions have completed. */ - public static Action> sequence(Seq> actions) { - Function, ? extends Seq>> cons = x -> xs -> xs.append(x); + public static Action> sequence(List> actions) { + Function, ? extends List>> cons = x -> xs -> xs.append(x); return actions.map(a -> a.map(cons)).foldLeft(pure(List.empty()), Action::ap); } @@ -229,17 +235,22 @@ public static Action> sequence(Seq> actions) { * @param Result type of the actions * @return A single action that returns if all sequenced actions have completed. */ - public static Action> traverse(Seq xs, Function> f) { + public static Action> traverse(List xs, Function> f) { return sequence(xs.map(f)); } /** - * Curried version of {@link #traverse(Seq, Function)}. + * Curried version of {@link #traverse(List, Function)}. */ - public static Function, Action>> traverse(Function> f) { + public static Function, Action>> traverse(Function> f) { return xs -> sequence(xs.map(f)); } + + public static W1 with(Action a) { + return new W1(a); + } + /** * Action that returns Unit. * This is analogous to a method that does nothing and returns void. @@ -259,7 +270,7 @@ public static Function, Action>> traverse(Function}, * and represent it as a {@code Function}. */ - public static Function toUnit(Consumer f) { + public static CheckedFunction1 toUnit(Consumer f) { return a -> { f.accept(a); return unit; diff --git a/src/main/java/previewcode/backend/services/actiondsl/ActionMain.java b/src/main/java/previewcode/backend/services/actiondsl/ActionMain.java deleted file mode 100644 index 93a6a1b..0000000 --- a/src/main/java/previewcode/backend/services/actiondsl/ActionMain.java +++ /dev/null @@ -1,83 +0,0 @@ -package previewcode.backend.services.actiondsl; - -import io.atlassian.fugue.Unit; -import previewcode.backend.DTO.*; - -import static previewcode.backend.services.actiondsl.ActionDSL.*; -import static previewcode.backend.services.actiondsl.ActionDSL.Action; -import static previewcode.backend.services.actions.GitHubActions.*; -import static previewcode.backend.services.actions.LogActions.*; - - - -class GitHubInterpreter extends Interpreter { - - final GitHubPullRequest pull = - new GitHubPullRequest("pull title", "these changes are awesome", "https://lol.com/", 123, - new PullRequestLinks("http://self", "http://html", "http://issue", "http://comments", - "http://statuses", "http://review_comments", "http://review_comment", "http://commits")); - - final GitHubStatus status = new GitHubStatus("pending", "Changes need ordering", - "preview-code/ordering", "http://status-ordering"); - - - public GitHubInterpreter() { - super(); - - on(GitHubFetchPullRequest.class).apply(fetchPull -> { - System.out.println("Pull!"); - return pull; - }); - - on(GitHubGetStatus.class).apply(getAction -> { - System.out.println("Getting status"); - return status; - }); - - on(GitHubSetStatus.class).apply(toUnit(setAction -> - System.out.println("Setting status") - )); - - on(GitHubPostComment.class).apply(toUnit(postAction -> - System.out.println("Posting comment") - )); - } -} - -class LogActionInterpreter extends Interpreter { - - public LogActionInterpreter() { - super(); - - on(LogInfo.class).apply(toUnit(logInfo -> - System.out.println("INFO: " + logInfo.message) - )); - } -} - -class ActionMain { - - public static void main(String... args) { - - Action gitHubAction = - new GitHubFetchPullRequest("preview-code", "backend", 42) // Get the PR from GitHub - .then(pullRequest -> new GitHubGetStatus(pullRequest) // Get the status of this PR - .then(maybeStatus -> - OrderingStatus.fromGitHubStatus(maybeStatus) - .map(OrderingStatus::complete) // If the status exists, set it to 'complete' - .map(status -> // Then post a comment and the new status - new GitHubPostComment(pullRequest, "This PR is now completed!") - .then(done -> new GitHubSetStatus(pullRequest, status)) - ) - .orElse(returnU))); // If the status is not there, just do nothing - - - Action composedAction = - new GitHubFetchPullRequest("preview-code", "backend", 42) - .then(p -> new LogInfo("Fetched PR")); - - new Interpreter(new GitHubInterpreter()) - .run(composedAction); - } - -} \ No newline at end of file diff --git a/src/main/java/previewcode/backend/services/actiondsl/CachingInterpreter.java b/src/main/java/previewcode/backend/services/actiondsl/CachingInterpreter.java new file mode 100644 index 0000000..d3beeac --- /dev/null +++ b/src/main/java/previewcode/backend/services/actiondsl/CachingInterpreter.java @@ -0,0 +1,58 @@ +package previewcode.backend.services.actiondsl; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import io.atlassian.fugue.Try; +import io.vavr.collection.List; +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +import javax.annotation.Nonnull; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * Caches results. + * Extend this class to make an interpreter that caches results. + *
+ *
+ * Use {@link ActionCache.Builder} to create and configure a cache. + * Keep in mind that sharing one cache for multiple interpreter is preferred + * for performance reasons. Therefore, you should first configure the cache for + * each interpreter and then create each interpreter using the configured cache. + *
+ *
+ * See {@link previewcode.backend.services.interpreters.GitHubAuthInterpreter} + * an {@link previewcode.backend.MainModule} for an example. + */ +public class CachingInterpreter extends Interpreter { + + final ActionCache cache; + + public CachingInterpreter(ActionCache cache) { + super(); + this.cache = cache; + } + + Cache, Object> getCache() { + return cache.getCache(); + } + + + @SuppressWarnings("unchecked") + @Override + protected
Try runLeaf(Action action) { + if (!cache.getExpireTimes().containsKey(action.getClass())) { + return super.runLeaf(action); + } + A cachedResult = (A) cache.getCache().getIfPresent(action); + if (cachedResult == null) { + Try result = super.runLeaf(action); + if (result.isSuccess()) { + cache.getCache().put(action, result.toOption().get()); + } + return result; + } + return Try.successful(cachedResult); + } +} diff --git a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java index 71c5ec3..834fff9 100644 --- a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java +++ b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java @@ -3,17 +3,18 @@ import io.atlassian.fugue.Either; import io.atlassian.fugue.Try; import io.atlassian.fugue.Unit; +import io.vavr.CheckedFunction1; import io.vavr.collection.List; import javax.ws.rs.core.Response; - -import static previewcode.backend.services.actiondsl.ActionDSL.*; - import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.function.Consumer; import java.util.function.Function; +import static previewcode.backend.services.actiondsl.ActionDSL.*; + public class Interpreter { private final Map, Object> handlers; @@ -43,7 +44,7 @@ public Interpreter returnA(A value) { * @param handler Function that handles actions of type {@code X} * @return An interpreter that handles {@code X} */ - public Interpreter apply(Function handler) { + public Interpreter apply(CheckedFunction1 handler) { interpreter.handlers.put(actionClass, handler); return interpreter; } @@ -102,6 +103,16 @@ public > InterpreterBuilder on(Class actionClass return new InterpreterBuilder<>(this, actionClass); } + + /** + * Create a stepped interpreter from the current interpreter. + * @param action The action to evaluate stepwise + * @param The result of the action + */ + public Stepper stepwiseEval(Action action) { + return new Stepper<>(this, action); + } + /** * Build an interpreter that does nothing for the given action. * @@ -116,7 +127,6 @@ public Interpreter ignore(Class> actionClass) { return this; } - /** * Evaluate an action with the current interpreter. * @@ -133,32 +143,34 @@ public Try evaluate(Action action) { * This method does not encapsulate errors in {@code Try}, * which means that any error will be thrown immediately. * - * @param action The action to run * @param Represents the result type + * @param action The action to run * @return The result of running the action - * @throws Exception when an error occurs during evaluation */ - public A unsafeEvaluate(Action action) throws Exception { + public A unsafeEvaluate(Action action) { Either result = evaluate(action).toEither(); if (result.isLeft()) { - throw result.left().get(); + return sneakyThrow(result.left().get()); } else { return result.right().get(); } } + @SuppressWarnings("unchecked") + private static R sneakyThrow(Throwable t) throws T { + throw (T) t; + } + /** - * Evaluate an action and build a response from the action result. + * Evaluate an action and buildCache a response from the action result. * @param action The action to evaluate * @return The response built from the action result. - * @throws Exception when an error occurs during evaluation */ - public Response evaluateToResponse(Action action) throws Exception { + public Response evaluateToResponse(Action action) { return Response.ok().entity(unsafeEvaluate(action)).build(); } - @SuppressWarnings("unchecked") protected Try run(Action action) { if (action == null) return Try.failure(new NullPointerException("Attempting to run a null action.")); @@ -174,21 +186,162 @@ protected Try run(Action action) { Try> applier = run(applyAction.f); return applicant.flatMap(x -> applier.map(fXA -> fXA.apply(x))); } else { - if (handlers.containsKey(action.getClass())) { - Function, ? extends A> handler = (Function, ? extends A>) handlers.get(action.getClass()); - try { - A result = handler.apply(action); - if (result != null) { - return Try.successful(result); + return runLeaf(action); + } + } + + @SuppressWarnings("unchecked") + protected Try runLeaf(Action action) { + if (handlers.containsKey(action.getClass())) { + CheckedFunction1, ? extends A> handler = (CheckedFunction1, ? extends A>) handlers.get(action.getClass()); + try { + A result = handler.apply(action); + if (result != null) { + return Try.successful(result); + } else { + throw new RuntimeException("Action handler for " + action.getClass().getSimpleName() + " returned null."); + } + } catch (Exception e) { + return Try.failure(e); + } catch (Throwable throwable) { + return sneakyThrow(throwable); + } + } else { + return Try.failure(new RuntimeException("Unexpected action: " + action)); + } + } + + /** + * Specialized interpreter that allows stepwise evaluation of an action. + * At each step, the Actions that will be evaluated on the next step can + * be retrieved using {@link Stepper#peek()}. + *
+ *
+ * This allows you to easilly inspect which effects an action has at each + * point in the computation. + * @param
+ */ + public static class Stepper { + + protected Action currentAction; + protected final Interpreter evaluator; + + private final class Done extends Action {} + + Stepper(Interpreter interpreter, Action currentAction) { + Objects.requireNonNull(currentAction); + this.currentAction = currentAction; + evaluator = interpreter; + } + + /** + * Evaluate the next iteration of the action that this Stepper was constructed with. + * Evaluates all actions that are currently visible with {@link Stepper#peek()}. + *
+ *
+ * Note that: + *
+         * {@code
+         *
+         * // a equals b
+         * List> a = stepper.next();
+         * List> b = stepper.peek();
+         * }
+         * 
+ * @return A list of all actions that will be evaluated on the next step. + * @throws DoneException if there is nothing more to evaluate. + */ + public List> next() { + if (currentAction instanceof Done) { + throw new DoneException(); + } else { + Try>> stepped = step(currentAction); + + if (stepped.isSuccess()) { + Either> result = stepped.toEither().right().get(); + if (result.isLeft()) { + currentAction = new Done<>(); } else { - throw new RuntimeException("Action handler for " + action.getClass().getSimpleName() + " returned null."); + currentAction = result.right().get(); } - } catch (Exception e) { - return Try.failure(e); + } else { + return sneakyThrow(stepped.toEither().left().get()); } + return peek(); + } + } + + /** + * Continue normal evaluation without pausing. + * This is equivalent to calling {@link Interpreter#unsafeEvaluate(Action)} + * with the current state of the Stepper. + * @return The result of running the entire action. + * @throws Exception when evaluation fails. + */ + public A runRemainingSteps() throws Exception { + return evaluator.unsafeEvaluate(currentAction); + } + + /** + * Returns all actions that would be evaluated in the next iteration. + * Note that not all effects in the action may be visible since sequential + * computations like {@code x.then(y)} only allow you to see {@code y} after + * {@code x} was evaluated. + *
+ *
+ * Use {@link Stepper#next()} to advance the computation. + * @return The list of all effects that are visible for the current action. + */ + public List> peek() { + return peek(currentAction); + } + + protected List> peek(Action action) { + if (action instanceof Done || action instanceof Return) { + return List.empty(); + } else if (action instanceof Suspend) { + return peek(((Suspend) action).action); + } else if (action instanceof Apply) { + return peek(((Apply) action).f).appendAll(peek(((Apply) action).action)); } else { - return Try.failure(new RuntimeException("Unexpected action: " + action)); + return List.of(action); } } + + protected Try>> step(Action action) { + if (action instanceof Suspend) { + Suspend suspendedAction = (Suspend) action; + + return step(suspendedAction.action).map(result -> { + if (result.isLeft()) { + return Either.right(suspendedAction.f.apply(result.left().get())); + } else { + return Either.right(new Suspend<>(suspendedAction.f, result.right().get())); + } + }); + } else if (action instanceof Apply) { + Apply applyAction = (Apply) action; + + return step(applyAction.action).flatMap(resultOrAction -> step(applyAction.f).map(funOrAction -> { + if (resultOrAction.isLeft()) { + if (funOrAction.isLeft()) { + return Either.left(funOrAction.left().get().apply(resultOrAction.left().get())); + } else { + return Either.right(new Apply<>(funOrAction.right().get(), pure(resultOrAction.left().get()))); + } + } else { + if (funOrAction.isLeft()) { + return Either.right(new Apply<>(pure(funOrAction.left().get()), resultOrAction.right().get())); + } else { + return Either.right(new Apply<>(funOrAction.right().get(), resultOrAction.right().get())); + } + } + })); + } else { + return evaluator.run(action).map(Either::left); + } + } + + public static class DoneException extends RuntimeException { } } } diff --git a/src/main/java/previewcode/backend/services/actiondsl/WithSyntax.java b/src/main/java/previewcode/backend/services/actiondsl/WithSyntax.java new file mode 100644 index 0000000..2a9a0e2 --- /dev/null +++ b/src/main/java/previewcode/backend/services/actiondsl/WithSyntax.java @@ -0,0 +1,153 @@ +package previewcode.backend.services.actiondsl; + +import io.vavr.*; +import previewcode.backend.services.actiondsl.ActionDSL.Action; + +import java.util.function.Function; + +public class WithSyntax { + + public static class W1
{ + private final Action a; + + public W1(Action a) { + this.a = a; + } + + public W2 and(Action b) { + return new W2<>(a,b); + } + } + + + public static class W2 { + private final Action a; + private final Action b; + + public W2(Action a, Action b) { + this.a = a; + this.b = b; + } + + public W3 and(Action c) { + return new W3<>(a, b, c); + } + + public Action apply(Function> f) { + return b.ap(a.map(f)); + } + + public Action apply(Function2 f) { + Action r = b.ap(a.map(f::apply)); + return r; + } + + public Action then(Function>> f) { + return apply(f).then(x -> x); + } + + public Action then(Function2> f) { + Action> r = apply(f); + Action r2 = r.then(x -> x); + return apply(f).then(x -> x); + } + } + + public static class W3 { + private final Action a; + private final Action b; + private final Action c; + + public W3(Action a, Action b, Action c) { + this.a = a; + this.b = b; + this.c = c; + } + + public W4 and(Action d) { + return new W4<>(a,b,c,d); + } + + public Action apply(Function3 f) { + return c.ap(b.ap(a.map(a -> f.curried().apply(a)))); + } + + public Action apply(Function>> f) { + return c.ap(b.ap(a.map(f))); + } + + public Action then(Function>>> f) { + return apply(f).then(x -> x); + } + + public Action then(Function3> f) { + return apply(f).then(x -> x); + } + } + + public static class W4 { + private final Action a; + private final Action b; + private final Action c; + private final Action d; + + public W4(Action a, Action b, Action c, Action d) { + this.a = a; + this.b = b; + this.c = c; + this.d = d; + } + + public W5 and(Action e) { + return new W5<>(a,b,c,d,e); + } + + public Action apply(Function4 f) { + return d.ap(c.ap(b.ap(a.map(a -> f.curried().apply(a))))); + } + + public Action apply(Function>>> f) { + return d.ap(c.ap(b.ap(a.map(f)))); + } + + public Action then(Function>>>> f) { + return apply(f).then(x -> x); + } + + public Action then(Function4> f) { + return apply(f).then(x -> x); + } + } + + public static class W5 { + private final Action a; + private final Action b; + private final Action c; + private final Action d; + private final Action e; + + public W5(Action a, Action b, Action c, Action d, Action e) { + this.a = a; + this.b = b; + this.c = c; + this.d = d; + this.e = e; + } + + public Action apply(Function5 f) { + return e.ap(d.ap(c.ap(b.ap(a.map(a -> f.curried().apply(a)))))); + } + + public Action apply(Function>>>> f) { + return e.ap(d.ap(c.ap(b.ap(a.map(f))))); + } + + public Action then(Function>>>>> f) { + return apply(f).then(x -> x); + } + + public Action then(Function5> f) { + return apply(f).then(x -> x); + } + } +} diff --git a/src/main/java/previewcode/backend/services/actions/DatabaseActions.java b/src/main/java/previewcode/backend/services/actions/DatabaseActions.java index ecf3b4a..4b38e06 100644 --- a/src/main/java/previewcode/backend/services/actions/DatabaseActions.java +++ b/src/main/java/previewcode/backend/services/actions/DatabaseActions.java @@ -1,19 +1,16 @@ package previewcode.backend.services.actions; +import com.sun.org.apache.xpath.internal.operations.Bool; import io.atlassian.fugue.Unit; import io.vavr.collection.List; +import previewcode.backend.DTO.ApproveStatus; import previewcode.backend.DTO.PullRequestIdentifier; -import previewcode.backend.database.GroupID; -import previewcode.backend.database.HunkID; -import previewcode.backend.database.PullRequestGroup; -import previewcode.backend.database.PullRequestID; +import previewcode.backend.database.*; import previewcode.backend.services.actiondsl.ActionDSL.*; public class DatabaseActions { - public static class DatabaseAction extends Action { } - - public static class FetchPull extends DatabaseAction { + public static class FetchPull extends Action { public final String owner; public final String name; public final Integer number; @@ -23,6 +20,26 @@ public FetchPull(PullRequestIdentifier pullRequestIdentifier) { this.name = pullRequestIdentifier.name; this.number = pullRequestIdentifier.number; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FetchPull fetchPull = (FetchPull) o; + + if (!owner.equals(fetchPull.owner)) return false; + if (!name.equals(fetchPull.name)) return false; + return number.equals(fetchPull.number); + } + + @Override + public int hashCode() { + int result = owner.hashCode(); + result = 31 * result + name.hashCode(); + result = 31 * result + number.hashCode(); + return result; + } } public static class InsertPullIfNotExists extends FetchPull { @@ -31,48 +48,103 @@ public InsertPullIfNotExists(PullRequestIdentifier pullRequestIdentifier) { } } - public static class NewGroup extends DatabaseAction { + public static class NewGroup extends Action { public final PullRequestID pullRequestId; public final String title; public final String description; + public final Boolean defaultGroup; - public NewGroup(PullRequestID pullRequestId, String title, String description) { + public NewGroup(PullRequestID pullRequestId, String title, String description, Boolean defaultGroup) { this.pullRequestId = pullRequestId; this.title = title; this.description = description; + this.defaultGroup = defaultGroup; } } - public static class AssignHunkToGroup extends DatabaseAction { + public static class AssignHunkToGroup extends Action { public final GroupID groupID; - public final String hunkIdentifier; + public final String hunkChecksum; - public AssignHunkToGroup(GroupID groupID, String hunkIdentifier) { + public AssignHunkToGroup(GroupID groupID, String hunkChecksum) { this.groupID = groupID; - this.hunkIdentifier = hunkIdentifier; + this.hunkChecksum = hunkChecksum; } } - public static class FetchGroupsForPull extends DatabaseAction> { + public static class FetchGroupsForPull extends Action> { public final PullRequestID pullRequestID; public FetchGroupsForPull(PullRequestID pullRequestID) { this.pullRequestID = pullRequestID; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FetchGroupsForPull that = (FetchGroupsForPull) o; + + return pullRequestID.equals(that.pullRequestID); + } + + @Override + public int hashCode() { + return pullRequestID.hashCode(); + } } - public static class FetchHunksForGroup extends DatabaseAction> { + public static class FetchHunksForGroup extends Action> { public final GroupID groupID; public FetchHunksForGroup(GroupID groupID) { this.groupID = groupID; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FetchHunksForGroup that = (FetchHunksForGroup) o; + + return groupID.equals(that.groupID); + } + + @Override + public int hashCode() { + return groupID.hashCode(); + } } - public static class DeleteGroup extends DatabaseAction { + public static class FetchHunkApprovals extends Action> { + public final HunkID hunkID; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + FetchHunkApprovals that = (FetchHunkApprovals) o; + + return hunkID.equals(that.hunkID); + } + + @Override + public int hashCode() { + return hunkID.hashCode(); + } + + public FetchHunkApprovals(HunkID hunkID) { + this.hunkID = hunkID; + } + } + + public static class DeleteGroup extends Action { public final GroupID groupID; public DeleteGroup(GroupID groupID) { @@ -80,7 +152,20 @@ public DeleteGroup(GroupID groupID) { } } + public static class ApproveHunk extends Action { + public final PullRequestID pullRequestID; + public final String hunkChecksum; + public final String githubUser; + public final ApproveStatus status; + public ApproveHunk(PullRequestID pullRequestID, String hunkChecksum, String githubUser, ApproveStatus status) { + + this.pullRequestID = pullRequestID; + this.hunkChecksum = hunkChecksum; + this.githubUser = githubUser; + this.status = status; + } + } public static InsertPullIfNotExists insertPullIfNotExists(PullRequestIdentifier pull) { return new InsertPullIfNotExists(pull); @@ -94,8 +179,8 @@ public static FetchPull fetchPull(String owner, String name, Integer number) { return new FetchPull(new PullRequestIdentifier(owner, name, number)); } - public static NewGroup newGroup(PullRequestID pullRequestId, String title, String description) { - return new NewGroup(pullRequestId, title, description); + public static NewGroup newGroup(PullRequestID pullRequestId, String title, String description, Boolean defaultGroup) { + return new NewGroup(pullRequestId, title, description, defaultGroup); } public static AssignHunkToGroup assignToGroup(GroupID groupID, String hunkId) { @@ -110,7 +195,16 @@ public static FetchHunksForGroup fetchHunks(GroupID groupID) { return new FetchHunksForGroup(groupID); } + public static FetchHunkApprovals fetchApprovals(HunkID hunkID) { + return new FetchHunkApprovals(hunkID); + } + public static DeleteGroup delete(GroupID groupID) { return new DeleteGroup(groupID); } + + public static ApproveHunk setApprove(PullRequestID pullRequestID, String hunkId, String githubUser, ApproveStatus approve) { + return new ApproveHunk(pullRequestID, hunkId, githubUser, approve); + } + } diff --git a/src/main/java/previewcode/backend/services/actions/GitHubActions.java b/src/main/java/previewcode/backend/services/actions/GitHubActions.java index aa1c7bf..d1abd4f 100644 --- a/src/main/java/previewcode/backend/services/actions/GitHubActions.java +++ b/src/main/java/previewcode/backend/services/actions/GitHubActions.java @@ -1,14 +1,157 @@ package previewcode.backend.services.actions; import io.atlassian.fugue.Unit; -import previewcode.backend.DTO.GitHubPullRequest; -import previewcode.backend.DTO.GitHubStatus; -import previewcode.backend.DTO.PullRequestIdentifier; +import io.vavr.Function2; +import previewcode.backend.DTO.*; + +import java.util.function.Function; import static previewcode.backend.services.actiondsl.ActionDSL.Action; +/** + * Actions for interacting with the GitHub API + */ public class GitHubActions { + public static Action authenticateInstallation(InstallationID id) { + return new AuthenticateInstallation(id); + } + + public static Function> authenticateInstallation = + GitHubActions::authenticateInstallation; + + + + public static VerifyWebhookSharedSecret verifyWebHookSecret(String requestBody, String sha1) { + return new VerifyWebhookSharedSecret(requestBody, sha1); + } + + public static Function2> verifyWebHookSecret = + GitHubActions::verifyWebHookSecret; + + + + public static IsWebHookUserAgent isWebHookUserAgent(String userAgent) { + return new IsWebHookUserAgent(userAgent); + } + + public static Function isWebHookUserAgent = + GitHubActions::isWebHookUserAgent; + + + + public static GetUser getUser(GitHubUserToken token) { + return new GetUser(token); + } + + public static Function getUser = GitHubActions::getUser; + + + public static class AuthenticateInstallation extends Action { + public final InstallationID installationID; + + public AuthenticateInstallation(InstallationID installationID) { + this.installationID = installationID; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AuthenticateInstallation that = (AuthenticateInstallation) o; + + return installationID.equals(that.installationID); + } + + @Override + public int hashCode() { + return installationID.hashCode(); + } + + @Override + public String toString() { + return "AuthenticateInstallation{" + + "installationID=" + installationID + + '}'; + } + } + + public static class VerifyWebhookSharedSecret extends Action { + public final String requestBody; + public final String sha1; + + public VerifyWebhookSharedSecret(String requestBody, String sha1) { + this.requestBody = requestBody; + this.sha1 = sha1; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + VerifyWebhookSharedSecret that = (VerifyWebhookSharedSecret) o; + + if (!requestBody.equals(that.requestBody)) return false; + return sha1.equals(that.sha1); + } + + @Override + public int hashCode() { + int result = requestBody.hashCode(); + result = 31 * result + sha1.hashCode(); + return result; + } + } + + public static class IsWebHookUserAgent extends Action { + public final String userAgent; + + public IsWebHookUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + IsWebHookUserAgent that = (IsWebHookUserAgent) o; + + return userAgent.equals(that.userAgent); + } + + @Override + public int hashCode() { + return userAgent.hashCode(); + } + } + + public static class GetUser extends Action { + public final GitHubUserToken token; + + public GetUser(GitHubUserToken token) { + this.token = token; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + GetUser getUser = (GetUser) o; + + return token.equals(getUser.token); + } + + @Override + public int hashCode() { + return token.hashCode(); + } + } + + public static class GitHubGetStatus extends Action { public final GitHubPullRequest pullRequest; @@ -48,5 +191,4 @@ public GitHubPostComment(GitHubPullRequest pull, String comment) { this.comment = comment; } } - } diff --git a/src/main/java/previewcode/backend/services/actions/RequestContextActions.java b/src/main/java/previewcode/backend/services/actions/RequestContextActions.java new file mode 100644 index 0000000..b0a4218 --- /dev/null +++ b/src/main/java/previewcode/backend/services/actions/RequestContextActions.java @@ -0,0 +1,83 @@ +package previewcode.backend.services.actions; + +import com.fasterxml.jackson.databind.JsonNode; +import io.vavr.control.Option; + +import static previewcode.backend.services.actiondsl.ActionDSL.*; + +/** + * Actions for interacting with the current request context. + */ +public class RequestContextActions { + + /** + * Get the user-agent string of the current request. + */ + public static Action getUserAgent = new GetUserAgent(); + + /** + * Get the body of the current request. + */ + public static Action getRequestBody = new GetRequestBody(); + + /** + * Get the body of the current request, respresented as JsonNode. + */ + public static Action getJsonBody = new GetJsonRequestBody(); + + /** + * Get a specific request header for the current request, + * Interpreters assume the header exists and throws a RuntimeException if it does not. + */ + public static Action getHeader(String header) { + return new GetHeader(header); + } + + /** + * Get a specific query parameter of the current request, + * returns {@link io.vavr.control.Option.None} if the query parameter was not present. + */ + public static Action> getQueryParam(String param) { + return new GetQueryParam(param); + } + + + + + public static class GetUserAgent extends Action {} + + public static class GetRequestBody extends Action {} + + public static class GetHeader extends Action { + public final String header; + + public GetHeader(String header) { + this.header = header; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + GetHeader getHeader = (GetHeader) o; + + return header.equals(getHeader.header); + } + + @Override + public int hashCode() { + return header.hashCode(); + } + } + + public static class GetQueryParam extends Action> { + public final String param; + + public GetQueryParam(String param) { + this.param = param; + } + } + + public static class GetJsonRequestBody extends Action { } +} diff --git a/src/main/java/previewcode/backend/services/http/HttpRequestExecutor.java b/src/main/java/previewcode/backend/services/http/HttpRequestExecutor.java new file mode 100644 index 0000000..a3069d4 --- /dev/null +++ b/src/main/java/previewcode/backend/services/http/HttpRequestExecutor.java @@ -0,0 +1,38 @@ +package previewcode.backend.services.http; + +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import previewcode.backend.api.exceptionmapper.HttpApiException; +import previewcode.backend.services.GithubService; + +import java.io.IOException; +import java.nio.file.Files; + +public class HttpRequestExecutor implements IHttpRequestExecutor { + private static final Logger logger = LoggerFactory.getLogger(GithubService.class); + + private final Cache cache = new Cache( + Files.createTempDirectory("preview-code-gh-cache").toFile(), + 10 * 1024 * 1024); + + private final OkHttpClient client = + new OkHttpClient.Builder().cache(cache).build(); + + public HttpRequestExecutor() throws IOException { } + + public String execute(Request request) { + logger.debug("[OKHTTP3] Executing request: " + request); + logger.debug("With Authorization hashcode: " + request.header("Authorization").hashCode()); + try (Response response = client.newCall(request).execute()) { + String body = response.body().string(); + if (response.isSuccessful()) { + return body; + } else { + throw new HttpApiException(body, response.code(), request.url()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/previewcode/backend/services/http/IHttpRequestExecutor.java b/src/main/java/previewcode/backend/services/http/IHttpRequestExecutor.java new file mode 100644 index 0000000..f5343ff --- /dev/null +++ b/src/main/java/previewcode/backend/services/http/IHttpRequestExecutor.java @@ -0,0 +1,14 @@ +package previewcode.backend.services.http; + +import okhttp3.Request; + +import java.io.IOException; + +/** + * Executes HTTP Requests. + * Used in tests to stub API calls. + */ +@FunctionalInterface +public interface IHttpRequestExecutor { + String execute(Request request) throws RuntimeException; +} diff --git a/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java b/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java new file mode 100644 index 0000000..9bba33d --- /dev/null +++ b/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java @@ -0,0 +1,139 @@ +package previewcode.backend.services.interpreters; + +import io.vavr.collection.List; +import org.jooq.DSLContext; +import org.jooq.InsertReturningStep; +import org.jooq.Record1; +import org.jooq.impl.DSL; +import previewcode.backend.database.*; +import previewcode.backend.database.model.tables.records.PullRequestRecord; +import previewcode.backend.services.actiondsl.Interpreter; + +import javax.inject.Inject; + +import static previewcode.backend.database.model.Tables.*; +import static previewcode.backend.services.actiondsl.ActionDSL.toUnit; +import static previewcode.backend.services.actions.DatabaseActions.*; + +public class DatabaseInterpreter extends Interpreter { + + private final DSLContext db; + + @Inject + public DatabaseInterpreter(DSLContext db) { + this.db = db; + on(FetchPull.class).apply(this::fetchPullRequest); + on(InsertPullIfNotExists.class).apply(this::insertPull); + on(NewGroup.class).apply(this::insertNewGroup); + on(AssignHunkToGroup.class).apply(toUnit(this::assignHunk)); + on(FetchGroupsForPull.class).apply(this::fetchGroups); + on(FetchHunksForGroup.class).apply(this::fetchHunks); + on(FetchHunkApprovals.class).apply(this::fetchApprovals); + on(DeleteGroup.class).apply(toUnit(this::deleteGroup)); + on(ApproveHunk.class).apply(toUnit(this::approveHunk)); + } + + private List fetchApprovals(FetchHunkApprovals action) { + return List.ofAll(db.selectFrom(APPROVAL) + .where(APPROVAL.HUNK_ID.eq(action.hunkID.id)) + .fetch() + .map(HunkApproval::fromRecord)); + } + + private List fetchHunks(FetchHunksForGroup action) { + return List.ofAll(db.selectFrom(HUNK) + .where(HUNK.GROUP_ID.eq(action.groupID.id)) + .fetch(Hunk::fromRecord)); + } + + protected void deleteGroup(DeleteGroup deleteGroup) { + db.deleteFrom(GROUPS) + .where(GROUPS.ID.eq(deleteGroup.groupID.id)) + .execute(); + } + + protected void assignHunk(AssignHunkToGroup action) { + int result = db.insertInto(HUNK) + .columns(HUNK.PULL_REQUEST_ID, HUNK.GROUP_ID, HUNK.CHECKSUM) + .select( + db.select(GROUPS.PULL_REQUEST_ID, DSL.val(action.groupID.id), DSL.val(action.hunkChecksum)) + .from(GROUPS) + .where(GROUPS.ID.eq(action.groupID.id)) + ) + .execute(); + + if (result == 0) { + throw new DatabaseException("Group not found."); + } + } + + protected void approveHunk(ApproveHunk action) { + + int result = db.insertInto(APPROVAL) + .columns(APPROVAL.HUNK_ID, APPROVAL.APPROVER, APPROVAL.STATUS) + .select( + db.select(HUNK.ID, DSL.val(action.githubUser), DSL.val(action.status.getApproved())) + .from(GROUPS) + .join(HUNK).on(GROUPS.ID.eq(HUNK.GROUP_ID)) + .where(GROUPS.PULL_REQUEST_ID.eq(action.pullRequestID.id) + .and(HUNK.CHECKSUM.eq(action.hunkChecksum)) + ) + ).onDuplicateKeyUpdate() + .set(APPROVAL.STATUS, action.status.getApproved()) + .execute(); + + if (result == 0) { + throw new DatabaseException("Pull request or hunk checksum not found."); + } + } + + + protected PullRequestID insertPull(InsertPullIfNotExists action) { + // Unchecked cast to InsertReturningStep because jOOQ 3.9.x does not support: + // INSERT INTO ... ON CONFLICT ... RETURNING + //noinspection unchecked + return new PullRequestID( + ((InsertReturningStep) + db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(action.owner, action.name, action.number) + .onConflict(PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .doUpdate() + .set(PULL_REQUEST.OWNER, action.owner)) + .returning(PULL_REQUEST.ID).fetchOne().getId() + ); + } + + protected PullRequestID fetchPullRequest(FetchPull action) { + Record1 pullIdRecord = db.select(PULL_REQUEST.ID) + .from(PULL_REQUEST) + .where( PULL_REQUEST.OWNER.eq(action.owner) + .and(PULL_REQUEST.NAME.eq(action.name)) + .and(PULL_REQUEST.NUMBER.eq(action.number))) + .fetchAny(); + + if (pullIdRecord != null) { + return new PullRequestID(pullIdRecord.value1()); + } else { + throw new DatabaseException("Could not find pull request with identifier: " + + action.owner + "/" + action.name + "/" + action.number); + } + } + + protected GroupID insertNewGroup(NewGroup newGroup) { + Boolean defaultGroup = newGroup.defaultGroup; + if(!newGroup.defaultGroup) { + defaultGroup = null; + } + return new GroupID( + db.insertInto(GROUPS, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION, GROUPS.DEFAULT_GROUP) + .values(newGroup.pullRequestId.id, newGroup.title, newGroup.description, defaultGroup) + .returning(GROUPS.ID).fetchOne().getId() + ); + } + + protected List fetchGroups(FetchGroupsForPull action) { + return List.ofAll(db.selectFrom(GROUPS) + .where(GROUPS.PULL_REQUEST_ID.eq(action.pullRequestID.id)) + .fetch(PullRequestGroup::fromRecord)); + } +} diff --git a/src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java b/src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java new file mode 100644 index 0000000..c5f294e --- /dev/null +++ b/src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java @@ -0,0 +1,164 @@ +package previewcode.backend.services.interpreters; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Key; +import com.google.inject.name.Names; +import io.atlassian.fugue.Unit; +import okhttp3.CacheControl; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.apache.commons.codec.binary.Hex; +import org.kohsuke.github.GitHub; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import previewcode.backend.DTO.GitHubUser; +import previewcode.backend.api.exceptionmapper.NotAuthorizedException; +import previewcode.backend.api.filter.GitHubAccessTokenFilter; +import previewcode.backend.api.filter.IJWTTokenCreator; +import previewcode.backend.services.http.IHttpRequestExecutor; +import previewcode.backend.services.GithubService; +import previewcode.backend.services.actiondsl.ActionCache; +import previewcode.backend.services.actiondsl.CachingInterpreter; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.inject.Inject; +import javax.inject.Named; +import javax.ws.rs.container.ContainerRequestContext; + +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.TimeUnit; + +import static previewcode.backend.services.actiondsl.ActionDSL.unit; +import static previewcode.backend.services.actions.GitHubActions.*; + +public class GitHubAuthInterpreter extends CachingInterpreter { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String GITHUB_WEBHOOK_USER_AGENT_PREFIX = "GitHub-Hookshot/"; + private static final Logger logger = LoggerFactory.getLogger(GitHubAccessTokenFilter.class); + private static final String CURRENT_USER_NAME = "github.user"; + private static final String CURRENT_TOKEN_BUILDER = "github.token.builder"; + private static final RequestBody EMPTY_REQUEST_BODY = RequestBody.create(null, new byte[]{}); + private static final String GH_MAN_MACHINE_PREVIEW_HEADER = "application/vnd.github.machine-man-preview+json"; + + + private final SecretKeySpec sharedSecret; + private final IJWTTokenCreator jwtTokenCreator; + private final IHttpRequestExecutor http; + private final String integrationId; + + // Ugly, should be gone when dynamic binding to @Named injections is not necessary anymore. + public ContainerRequestContext context; + + @Inject + public GitHubAuthInterpreter( + @Named("integration.id") String integrationId, + @Named("github.webhook.secret") SecretKeySpec sharedSecret, + IJWTTokenCreator jwtTokenCreator, + IHttpRequestExecutor http, + ActionCache cache) { + super(cache); + this.integrationId = integrationId; + this.sharedSecret = sharedSecret; + this.jwtTokenCreator = jwtTokenCreator; + this.http = http; + + on(IsWebHookUserAgent.class).apply(this::isWebHook); + on(VerifyWebhookSharedSecret.class).apply(this::verifySharedSecret); + on(GetUser.class).apply(this::fetchUser); + on(AuthenticateInstallation.class).apply(this::authInstallation); + } + + public static ActionCache.Builder configure(ActionCache.Builder b) { + return b.expire(GetUser.class).afterWrite(15, TimeUnit.MINUTES) + .expire(AuthenticateInstallation.class).afterWrite(1, TimeUnit.HOURS); + } + + protected Unit authInstallation(AuthenticateInstallation action) throws IOException { + String token = jwtTokenCreator.create(integrationId); + + logger.info("Authenticating installation {" + action.installationID + "} as integration {" + integrationId + "}"); + String installationToken = this.authenticateInstallation(action.installationID.id, token); + + GithubService.TokenBuilder builder = (Request.Builder request) -> + request.header("Authorization", "token " + installationToken) + .addHeader("Accept", GH_MAN_MACHINE_PREVIEW_HEADER); + + context.setProperty(Key.get(GithubService.TokenBuilder.class, Names.named(CURRENT_TOKEN_BUILDER)).toString(), builder); + return unit; + } + + private String authenticateInstallation(String installationId, String integrationToken) throws IOException { + Request request = new Request.Builder() + .url("https://api.github.com/installations/" + installationId + "/access_tokens") + .addHeader("Accept", GH_MAN_MACHINE_PREVIEW_HEADER) + .addHeader("Authorization", "Bearer " + integrationToken) + .post(EMPTY_REQUEST_BODY) + .build(); + + String response = http.execute(request); + return MAPPER.readValue(response, JsonNode.class).get("token").asText(); + } + + protected GitHubUser fetchUser(GetUser action) throws RuntimeException, IOException { + Request r = new Request.Builder() + .addHeader("Authorization", "token " + action.token.token) + .url("https://api.github.com/user") + .cacheControl(new CacheControl.Builder() + .maxAge(15, TimeUnit.MINUTES) + .build()) + .get().build(); + + logger.info("Authenticating user via OAuth"); + authViaOldApi(action); + + registerUserToken(action.token.token); + return fromJson(http.execute(r), GitHubUser.class); + } + + protected void registerUserToken(String token) { + GithubService.TokenBuilder builder = (Request.Builder request) -> + request.header("Authorization", "token " + token); + + context.setProperty(Key.get(GithubService.TokenBuilder.class, Names.named(CURRENT_TOKEN_BUILDER)).toString(), builder); + } + + protected void authViaOldApi(GetUser action) throws IOException { + GitHub gitHub = GitHub.connectUsingOAuth(action.token.token); + context.setProperty(Key.get(GitHub.class, Names.named(CURRENT_USER_NAME)).toString(), gitHub); + } + + protected Unit verifySharedSecret(VerifyWebhookSharedSecret action) { + if (action.sha1 != null && action.sha1.startsWith("sha1=")) { + try { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(sharedSecret); + final String expectedHash = Hex.encodeHexString(mac.doFinal(action.requestBody.getBytes())); + if (!action.sha1.equals("sha1="+ expectedHash)) { + throw new NotAuthorizedException("MAC verification failed"); + } + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new RuntimeException("Error instantiating HmacSHA1.", e); + } + } else { + throw new NotAuthorizedException("Missing or invalid MAC header."); + } + return unit; + } + + protected Boolean isWebHook(IsWebHookUserAgent action) { + return action.userAgent != null && action.userAgent.startsWith(GITHUB_WEBHOOK_USER_AGENT_PREFIX); + } + + private T fromJson(String body, Class destClass) { + try { + return MAPPER.readValue(body, destClass); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/previewcode/backend/services/interpreters/RequestContextActionInterpreter.java b/src/main/java/previewcode/backend/services/interpreters/RequestContextActionInterpreter.java new file mode 100644 index 0000000..26e60ff --- /dev/null +++ b/src/main/java/previewcode/backend/services/interpreters/RequestContextActionInterpreter.java @@ -0,0 +1,63 @@ +package previewcode.backend.services.interpreters; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.vavr.control.Option; +import org.apache.commons.io.IOUtils; +import org.jboss.resteasy.spi.HttpRequest; +import previewcode.backend.services.actiondsl.Interpreter; + +import javax.ws.rs.NotAuthorizedException; +import java.io.IOException; +import java.io.InputStream; + +import static previewcode.backend.services.actions.RequestContextActions.*; + +public class RequestContextActionInterpreter extends Interpreter { + + private final HttpRequest request; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private String requestBody; + private JsonNode jsonBody; + + + public RequestContextActionInterpreter(HttpRequest request) { + super(); + this.request = request; + + on(GetHeader.class).apply(this::getHeader); + on(GetUserAgent.class).apply(__ -> request.getHttpHeaders().getHeaderString("User-Agent")); + on(GetQueryParam.class).apply(this::getQueryParam); + on(GetRequestBody.class).apply(__ -> getRequestBody()); + on(GetJsonRequestBody.class).apply(__ -> getJsonBody()); + } + + private String getHeader(GetHeader action) { + String header = request.getHttpHeaders().getRequestHeaders().getFirst(action.header); + if (header == null) { + throw new NotAuthorizedException("Expected header: " + action.header + " to be present"); + } else { + return header; + } + } + + private JsonNode getJsonBody() throws IOException { + if (jsonBody != null) return jsonBody; + this.jsonBody = MAPPER.readTree(getRequestBody()); + return this.jsonBody; + } + + private Option getQueryParam(GetQueryParam action) { + return Option.of(request.getUri().getQueryParameters().getFirst(action.param)); + } + + private String getRequestBody() throws IOException { + if (requestBody != null) return requestBody; + + try (InputStream inputStream = request.getInputStream()) { + requestBody = IOUtils.toString(inputStream, "UTF-8"); + request.setInputStream(IOUtils.toInputStream(requestBody, "UTF-8")); + return requestBody; + } + } +} diff --git a/src/main/resources/db-migration/V1__initialise_database.sql b/src/main/resources/db-migration/V1__initialise_database.sql index 1cb10ee..f025d63 100644 --- a/src/main/resources/db-migration/V1__initialise_database.sql +++ b/src/main/resources/db-migration/V1__initialise_database.sql @@ -21,26 +21,35 @@ CREATE TABLE preview_code.groups ( id BIGINT DEFAULT nextval('preview_code.seq_pk_groups') NOT NULL CONSTRAINT pk_groups PRIMARY KEY, title VARCHAR NOT NULL, description VARCHAR NOT NULL, - pull_request_id BIGINT NOT NULL CONSTRAINT fk_groups_pull_request REFERENCES preview_code.pull_request(id) + pull_request_id BIGINT NOT NULL CONSTRAINT fk_groups_pull_request REFERENCES preview_code.pull_request(id), + default_group BOOLEAN, + + CONSTRAINT unique_default_group UNIQUE (default_group, pull_request_id) + ); CREATE INDEX fk_group_pull_id ON preview_code.groups (pull_request_id); +CREATE SEQUENCE preview_code.seq_pk_hunk; + CREATE TABLE preview_code.hunk ( - id VARCHAR NOT NULL, - group_id BIGINT NOT NULL CONSTRAINT fk_hunk_group_id REFERENCES preview_code.groups(id), + id BIGINT DEFAULT nextval('preview_code.seq_pk_hunk') NOT NULL CONSTRAINT pk_hunk PRIMARY KEY, + checksum VARCHAR NOT NULL, + group_id BIGINT NOT NULL CONSTRAINT fk_hunk_group_id REFERENCES preview_code.groups(id) ON DELETE CASCADE, + pull_request_id BIGINT NOT NULL CONSTRAINT fk_hunk_pull_id REFERENCES preview_code.pull_request(id), - CONSTRAINT unique_hunkId_groupId UNIQUE (id, group_id) + CONSTRAINT unique_hunkId_groupId UNIQUE (checksum, pull_request_id) ); CREATE INDEX idx_fk_hunk_group_id ON preview_code.hunk (group_id); CREATE TABLE preview_code.approval ( - pull_request_id BIGINT NOT NULL CONSTRAINT fk_approval_pull_request REFERENCES preview_code.pull_request(id), - hunk_id VARCHAR NOT NULL, + hunk_id BIGINT NOT NULL CONSTRAINT fk_approval_hunk REFERENCES preview_code.hunk(id), + approver VARCHAR NOT NULL, + status VARCHAR NOT NULL, - CONSTRAINT pk_approval PRIMARY KEY (pull_request_id, hunk_id) + CONSTRAINT pk_approval PRIMARY KEY (hunk_id, approver) ) diff --git a/src/test/java/previewcode/backend/DTO/DiffTest.java b/src/test/java/previewcode/backend/DTO/DiffTest.java new file mode 100644 index 0000000..a20bed6 --- /dev/null +++ b/src/test/java/previewcode/backend/DTO/DiffTest.java @@ -0,0 +1,322 @@ +package previewcode.backend.DTO; + + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class DiffTest { + + @Test + public void Diff1() { + Diff diff = new Diff("diff --git a/a b/a\n" + + "new file mode 100644\n" + + "index 0000000..e3c0674\n" + + "--- /dev/null\n" + + "+++ b/a\n" + + "@@ -0,0 +1 @@\n" + + "+one line"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "YSwwLDE="); + } + + @Test + public void Diff2() { + Diff diff = new Diff("diff --git a/b b/b\n" + + "index e3c0674..ca5d643 100644\n" + + "--- a/b\n" + + "+++ b/b\n" + + "@@ -1 +1,2 @@\n" + + " one line\n" + + "+line two"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "YiwxLDE="); + } + + @Test + public void Diff3() { + Diff diff = new Diff("diff --git a/c b/c\n" + + "index ca5d643..bf475b0 100644\n" + + "--- a/c\n" + + "+++ b/c\n" + + "@@ -1,2 +1,3 @@\n" + + " one line\n" + + "+line inbetween\n" + + " line two"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "YywxLDE="); + } + + @Test + public void Diff4() { + Diff diff = new Diff("diff --git a/d b/d\n" + + "index bf475b0..88afc4f 100644\n" + + "--- a/d\n" + + "+++ b/d\n" + + "@@ -1,3 +1,5 @@\n" + + " one line\n" + + " line inbetween\n" + + "+another line\n" + + "+hello world\n" + + " line two"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "ZCwxLDE="); + } + @Test + public void Diff5() { + Diff diff = new Diff("diff --git a/e b/e\n" + + "index 88afc4f..0a96631 100644\n" + + "--- a/e\n" + + "+++ b/e\n" + + "@@ -1,5 +1,5 @@\n" + + " one line\n" + + " line inbetween\n" + + " another line\n" + + "-hello world\n" + + "+replace a line\n" + + " line two"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "ZSwxLDE="); + } + @Test + public void Diff6() { + Diff diff = new Diff("diff --git a/f b/f\n" + + "index 0a96631..c8f047a 100644\n" + + "--- a/f\n" + + "+++ b/f\n" + + "@@ -1,5 +1,5 @@\n" + + " one line\n" + + " line inbetween\n" + + "-another line\n" + + " replace a line\n" + + "+another line\n" + + " line two"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "ZiwxLDE="); + } + @Test + public void Diff7() { + Diff diff = new Diff("diff --git a/g b/g\n" + + "index c8f047a..35cced2 100644\n" + + "--- a/g\n" + + "+++ b/g\n" + + "@@ -1,5 +1,4 @@\n" + + " one line\n" + + " line inbetween\n" + + "-replace a line\n" + + " another line\n" + + " line two"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "ZywxLDE="); + } + @Test + public void Diff8() { + Diff diff = new Diff("diff --git a/h b/h\n" + + "index 35cced2..b77e78a 100644\n" + + "--- a/h\n" + + "+++ b/h\n" + + "@@ -1,4 +1,3 @@\n" + + " one line\n" + + " line inbetween\n" + + " another line\n" + + "-line two"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "aCwxLDE="); + } + @Test + public void Diff9() { + Diff diff = new Diff("diff --git a/i b/i\n" + + "index b77e78a..ba817a3 100644\n" + + "--- a/i\n" + + "+++ b/i\n" + + "@@ -1,3 +1,3 @@\n" + + "-one line\n" + + " line inbetween\n" + + " another line\n" + + "+add last"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "aSwxLDE="); + } + @Test + public void Diff10() { + Diff diff = new Diff("diff --git a/j b/j\n" + + "index ba817a3..56ed89c 100644\n" + + "--- a/j\n" + + "+++ b/j\n" + + "@@ -1,3 +1,2 @@\n" + + "-line inbetween\n" + + " another line\n" + + " add last"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "aiwxLDE="); + } + @Test + public void Diff11() { + Diff diff = new Diff("diff --git a/k b/k\n" + + "index 56ed89c..e69de29 100644\n" + + "--- a/k\n" + + "+++ b/k\n" + + "@@ -1,2 +0,0 @@\n" + + "-another line\n" + + "-add last"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "aywxLDA="); + } + @Test + public void Diff12() { + Diff diff = new Diff("diff --git a/l b/l\n" + + "index 4dbe2d7..4dc0b35 100644\n" + + "--- a/l\n" + + "+++ b/l\n" + + "@@ -3,8 +3,6 @@ Fusce laoreet dui in lectus tempus, ut vulputate nisi porttitor.\n" + + " Pellentesque a nulla a libero molestie blandit vitae id eros.\n" + + " Maecenas sit amet turpis condimentum enim volutpat imperdiet.\n" + + " Vestibulum at sem convallis, congue erat porttitor, mattis dui.\n" + + "-Donec scelerisque massa in dignissim egestas.\n" + + "-Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Integer non turpis eu quam bibendum pulvinar vel non ante.\n" + + " Etiam vel nibh aliquam, dignissim nunc vel, ultrices sapien.\n" + + " Nulla ac justo non tellus convallis suscipit.\n" + + "@@ -340,6 +338,8 @@ Vestibulum sed sem fermentum, tristique diam ut, aliquam mauris.\n" + + " Sed malesuada orci non pulvinar dictum.\n" + + " Etiam convallis augue nec posuere convallis.\n" + + " Proin molestie turpis a orci ultricies, nec porta urna fringilla.\n" + + "+Donec scelerisque massa in dignissim egestas.\n" + + "+Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Cras aliquet lacus eget urna sagittis hendrerit.\n" + + " Curabitur suscipit ipsum non mollis vestibulum.\n" + + " Suspendisse tempus purus ac tellus pharetra fringilla."); + + assertEquals(diff.getHunkChecksums().size(), 2); + assertEquals(diff.getHunkChecksums().get(0).checksum, "bCwzLDM="); + assertEquals(diff.getHunkChecksums().get(1).checksum, "bCwzNDAsMzM4"); + } + @Test + public void Diff13() { + Diff diff = new Diff("diff --git a/m b/m\n" + + "index efe6276..6114b7a 100644\n" + + "--- a/m\n" + + "+++ b/m\n" + + "@@ -343,3 +341,5 @@ Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Pellentesque a nulla a libero molestie blandit vitae id eros.\n" + + " Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Integer non turpis eu quam bibendum pulvinar vel non ante.\n" + + "+Etiam vel nibh aliquam, dignissim nunc vel, ultrices sapien.\n" + + "+Nulla ac justo non tellus convallis suscipit."); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "bSwzNDMsMzQx"); + } + @Test + public void Diff14() { + Diff diff = new Diff("diff --git a/n b/n\n" + + "index efe6276..6114b7a 100644\n" + + "--- a/n\n" + + "+++ b/n\n" + + "@@ -341,5 +343,3 @@ Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Pellentesque a nulla a libero molestie blandit vitae id eros.\n" + + " Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Integer non turpis eu quam bibendum pulvinar vel non ante.\n" + + "-Etiam vel nibh aliquam, dignissim nunc vel, ultrices sapien.\n" + + "-Nulla ac justo non tellus convallis suscipit."); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "biwzNDEsMzQz"); + } + @Test + public void Diff15() { + Diff diff = new Diff("diff --git a/a b/o\n" + + "index efe6276..6114b7a 100644\n" + + "--- a/a\n" + + "+++ b/o\n" + + "@@ -341,5 +343,3 @@ Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Pellentesque a nulla a libero molestie blandit vitae id eros.\n" + + " Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + " Integer non turpis eu quam bibendum pulvinar vel non ante.\n" + + "-Etiam vel nibh aliquam, dignissim nunc vel, ultrices sapien.\n" + + "-Nulla ac justo non tellus convallis suscipit."); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "bywzNDEsMzQz"); + } + @Test + public void Diff16() { + Diff diff = new Diff("diff --git a/q b/q\n" + + "index efe6276..6114b7a 100644\n" + + "--- a/q\n" + + "+++ b/q\n" + + "@@ -3,2 +3,1 @@ \n" + + "First hunk .\n" + + "-first hunk line two.\n" + + "@@ -340,1 +338,3 @@ \n" + + "second hunk line 1\n" + + "+two two\n" + + "+two three.\n" + + "@@ -512,1 +514,3 @@ \n" + + "Third hunk first line.\n" + + "+ Third second.\n" + + "+Last line in last hunk."); + + assertEquals(diff.getHunkChecksums().size(), 3); + assertEquals(diff.getHunkChecksums().get(0).checksum, "cSwzLDM="); + assertEquals(diff.getHunkChecksums().get(1).checksum, "cSwzNDAsMzM4"); + assertEquals(diff.getHunkChecksums().get(2).checksum, "cSw1MTIsNTE0"); + } + @Test + public void Diff17() { + Diff diff = new Diff("diff --git a/r b/s\n" + + "similarity index 100% \n" + + "rename from new \n" + + "rename to renamed."); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "cyx1bmRlZmluZWQsdW5kZWZpbmVk"); + + } + + @Test + public void Diff18() { + Diff diff = new Diff("diff --git a/r b/r\n" + + "index efe6276..6114b7a 100644\n" + + "--- a/r\n" + + "+++ b/r\n" + + "@@ -3,2 +3,1 @@ \n" + + "First hunk .\n" + + "+ @@ -3,2 +3,1 @@"); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "ciwzLDM="); + + } + + @Test + public void Diff19() { + Diff diff = new Diff("diff --git a/p /dev/null\n" + + "deleted file mode 100644\n" + + "index efe6276..6114b7a 100644\n" + + "--- a/p\n" + + "+++ /dev/null\n" + + "@@ -341,5 +343,3 @@ Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + "-Pellentesque a nulla a libero molestie blandit vitae id eros.\n" + + "-Morbi fermentum neque sit amet ante eleifend, non molestie nulla pretium.\n" + + "-Integer non turpis eu quam bibendum pulvinar vel non ante.\n" + + "-Etiam vel nibh aliquam, dignissim nunc vel, ultrices sapien.\n" + + "-Nulla ac justo non tellus convallis suscipit."); + + assertEquals(diff.getHunkChecksums().size(), 1); + assertEquals(diff.getHunkChecksums().get(0).checksum, "cCwzNDEsMzQz"); + } +} diff --git a/src/test/java/previewcode/backend/api/v2/EndPointTest.java b/src/test/java/previewcode/backend/api/v2/EndPointTest.java index 8a4b211..503fd2c 100644 --- a/src/test/java/previewcode/backend/api/v2/EndPointTest.java +++ b/src/test/java/previewcode/backend/api/v2/EndPointTest.java @@ -1,23 +1,21 @@ package previewcode.backend.api.v2; +import com.google.inject.name.Names; import io.atlassian.fugue.Unit; -import io.vavr.collection.Seq; +import io.vavr.collection.List; import org.junit.jupiter.api.Test; import previewcode.backend.APIModule; -import previewcode.backend.DTO.OrderingGroup; -import previewcode.backend.DTO.PullRequestIdentifier; +import previewcode.backend.DTO.*; +import previewcode.backend.services.actiondsl.Interpreter; import previewcode.backend.test.helpers.ApiEndPointTest; import previewcode.backend.database.PullRequestGroup; import previewcode.backend.services.IDatabaseService; -import previewcode.backend.services.actiondsl.ActionDSL.*; -import previewcode.backend.services.actiondsl.Interpreter; import javax.ws.rs.client.Entity; import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.Response; import java.util.ArrayList; -import java.util.List; import static org.assertj.core.api.Assertions.*; import static previewcode.backend.services.actiondsl.ActionDSL.*; @@ -40,12 +38,44 @@ public void testApiIsReachable(WebTarget target) { @Test public void orderingApiIsReachable(WebTarget target) { - List emptyList = new ArrayList<>(); - Response response = target .path("/v2/preview-code/backend/pulls/42/ordering") .request("application/json") - .post(Entity.json(emptyList)); + .post(Entity.json(new ArrayList<>())); + + assertThat(response.getLength()).isZero(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void setApprovedApiIsReachable(WebTarget target) { + Response response = target + .path("/v2/preview-code/backend/pulls/42/setApprove") + .request("application/json") + .post(Entity.json("{}")); + + assertThat(response.getLength()).isZero(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + public void getApprovalsApiIsReachable(WebTarget target) { + Response response = target + .path("/v2/preview-code/backend/pulls/42/getApprovals") + .request("application/json") + .get(); + + assertThat(response.getLength()).isZero(); + assertThat(response.getStatus()).isEqualTo(200); + } + + + @Test + public void getHunkApprovalsApiIsReachable(WebTarget target) { + Response response = target + .path("/v2/preview-code/backend/pulls/42/getHunkApprovals") + .request("application/json") + .get(); assertThat(response.getLength()).isZero(); assertThat(response.getStatus()).isEqualTo(200); @@ -57,7 +87,17 @@ class TestModule extends APIModule implements IDatabaseService { public TestModule() {} @Override - public Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, Seq body) { + public Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, List body) { + return new NoOp<>(); + } + + @Override + public Action insertDefaultGroup(PullRequestIdentifier pullRequestIdentifier, OrderingGroup body) { + return new NoOp<>(); + } + + @Override + public Action setApproval(PullRequestIdentifier pullRequestIdentifier, ApproveRequest approve) { return new NoOp<>(); } @@ -66,6 +106,16 @@ public Action> fetchPullRequestGroups( return new NoOp<>(); } + @Override + public Action getApproval(PullRequestIdentifier pull) { + return new NoOp<>(); + } + + @Override + public Action> getHunkApprovals(PullRequestIdentifier pull) { + return new NoOp<>(); + } + @SuppressWarnings("unchecked") @Override @@ -74,9 +124,9 @@ public void configureServlets() { // The DatabaseService always returns a no-op action this.bind(IDatabaseService.class).toInstance(this); - // The interpreter always evaluates any action to Unit - this.bind(Interpreter.class).toInstance( - new Interpreter().on(NoOp.class).apply(x -> unit) +// The interpreter always evaluates any action to Unit + this.bind(Interpreter.class).annotatedWith(Names.named("database-interp")).toInstance( + interpret().on(NoOp.class).apply(x -> unit) ); } } diff --git a/src/test/java/previewcode/backend/api/v2/RequestContextActionInterpreterTest.java b/src/test/java/previewcode/backend/api/v2/RequestContextActionInterpreterTest.java new file mode 100644 index 0000000..b0d30c9 --- /dev/null +++ b/src/test/java/previewcode/backend/api/v2/RequestContextActionInterpreterTest.java @@ -0,0 +1,106 @@ +package previewcode.backend.api.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import io.vavr.control.Option; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.junit.jupiter.api.Test; +import previewcode.backend.services.interpreters.RequestContextActionInterpreter; + +import javax.ws.rs.NotAuthorizedException; +import java.net.URISyntaxException; + +import static org.assertj.core.api.Assertions.*; +import static previewcode.backend.services.actions.RequestContextActions.*; + +public class RequestContextActionInterpreterTest { + + @Test + void getRequestBody_readsContent_withInjectedRequest() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant").content("Hello Content".getBytes()); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + String body = interpreter.unsafeEvaluate(getRequestBody); + assertThat(body).isEqualTo("Hello Content"); + } + + @Test + void getRequestBody_readsContent_withoutConsumingTheStream() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant").content("Hello Content".getBytes()); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + String body = interpreter.unsafeEvaluate(getRequestBody.then(getRequestBody)); + assertThat(body).isEqualTo("Hello Content"); + } + + @Test + void getHeader_readsHeader_fromInjectedRequest() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant").header("abc", "def"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + String header = interpreter.unsafeEvaluate(getHeader("abc")); + assertThat(header).isEqualTo("def"); + } + + @Test + void getHeader_returnsFirstValueOnly() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant") + .header("abc", "def") + .header("abc", "ghi"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + String header = interpreter.unsafeEvaluate(getHeader("abc")); + assertThat(header).isEqualTo("def"); + } + + @Test + void getHeader_throws_whenHeaderIsNotPresent() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant").header("abc", "def"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + assertThatExceptionOfType(NotAuthorizedException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(getHeader("xyz"))); + } + + @Test + void getUserAgent_returnsUserAgent() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant").header("User-Agent", "qwerty"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + String userAgent = interpreter.unsafeEvaluate(getUserAgent); + assertThat(userAgent).isEqualTo("qwerty"); + } + + @Test + void getQueryParam_returnsTheParam() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant.com/?name=txsmith"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + Option name = interpreter.unsafeEvaluate(getQueryParam("name")); + assertThat(name).isEqualTo(Option.of("txsmith")); + } + + @Test + void getQueryParam_decodesTheParam() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant.com/?name=tx%20smith"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + Option name = interpreter.unsafeEvaluate(getQueryParam("name")); + assertThat(name).isEqualTo(Option.of("tx smith")); + } + + @Test + void getQueryParam_returnsFirstParam() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant.com/?name=txsmith&name=eanker"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + Option name = interpreter.unsafeEvaluate(getQueryParam("name")); + assertThat(name).isEqualTo(Option.of("txsmith")); + } + + @Test + void getQueryParam_returnsNoneWhenNotPresent() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant.com/?name=txsmith"); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + Option salary = interpreter.unsafeEvaluate(getQueryParam("salary")); + assertThat(salary).isEqualTo(Option.none()); + } + + @Test + void getJsonBody_parsesBodyToJson() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("irrelevant").content("{ \"name\": \"txsmith\" }".getBytes()); + RequestContextActionInterpreter interpreter = new RequestContextActionInterpreter(request); + JsonNode jsonNode = interpreter.unsafeEvaluate(getJsonBody); + assertThat(jsonNode.get("name").asText()).isEqualTo("txsmith"); + } +} \ No newline at end of file diff --git a/src/test/java/previewcode/backend/api/v2/WrapppedTypeDeserializingTest.java b/src/test/java/previewcode/backend/api/v2/WrapppedTypeDeserializingTest.java new file mode 100644 index 0000000..77fc73f --- /dev/null +++ b/src/test/java/previewcode/backend/api/v2/WrapppedTypeDeserializingTest.java @@ -0,0 +1,29 @@ +package previewcode.backend.api.v2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import previewcode.backend.DTO.HunkChecksum; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WrapppedTypeDeserializingTest { + + @Test + void deserialize_hunkChecksum() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + + HunkChecksum readValue = mapper.readValue("\"abcd\"", HunkChecksum.class); + assertThat(readValue.checksum).isEqualTo("abcd"); + } + + @Test + void serialize_hunkChecksum() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + + HunkChecksum h = new HunkChecksum("abcd"); + + assertThat(mapper.writeValueAsString(h)).isEqualTo("\"abcd\""); + } +} diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java index 3569e47..8864a63 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreterTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach; import previewcode.backend.DTO.PullRequestIdentifier; import previewcode.backend.services.actiondsl.ActionDSL; +import previewcode.backend.services.interpreters.DatabaseInterpreter; import previewcode.backend.test.helpers.DatabaseTests; @DatabaseTests @@ -26,7 +27,7 @@ public void setup(DSLContext db) { this.dbInterpreter = new DatabaseInterpreter(db); } - protected T eval(ActionDSL.Action action) throws Exception { + protected T eval(ActionDSL.Action action){ return dbInterpreter.unsafeEvaluate(action); } } diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_ApprovalTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_ApprovalTest.java new file mode 100644 index 0000000..4342d8a --- /dev/null +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_ApprovalTest.java @@ -0,0 +1,112 @@ +package previewcode.backend.database; + +import io.vavr.collection.List; +import org.jooq.DSLContext; +import org.jooq.exception.DataAccessException; +import org.jooq.impl.DSL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import previewcode.backend.DTO.ApproveStatus; +import previewcode.backend.database.model.tables.records.ApprovalRecord; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static previewcode.backend.database.model.Tables.*; +import static previewcode.backend.services.actions.DatabaseActions.*; + + +public class DatabaseInterpreter_ApprovalTest extends DatabaseInterpreterTest { + + private static final String hunkChecksum = "jkl"; + private static final String githubUser = "user"; + private static final ApproveStatus statusApproved = ApproveStatus.APPROVED; + + private static final PullRequestID dbPullId = new PullRequestID(42L); + + @BeforeEach + @Override + public void setup(DSLContext db) { + super.setup(db); + + db.insertInto(PULL_REQUEST, PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) + .values(dbPullId.id, owner, name, number) + .execute(); + + db.insertInto(GROUPS, GROUPS.TITLE, GROUPS.DESCRIPTION, GROUPS.PULL_REQUEST_ID) + .values("Group A", "AA", dbPullId.id).execute(); + + db.insertInto(HUNK, HUNK.GROUP_ID, HUNK.CHECKSUM, HUNK.PULL_REQUEST_ID) + .values(1L, "abc", dbPullId.id) + .values(1L, "def", dbPullId.id) + .values(1L, "ghi", dbPullId.id) + .values(1L, "jkl", dbPullId.id) + .values(1L, "mno", dbPullId.id) + .execute(); + } + + @Test + public void approveHunk_insertApproval(DSLContext db){ + eval(setApprove(dbPullId, hunkChecksum, githubUser, statusApproved)); + + Integer approveCount = db.selectCount().from(APPROVAL).fetchOne().value1(); + assertThat(approveCount).isEqualTo(1); + } + + @Test + public void approveHunk_insertsCorrectData(DSLContext db){ + ApproveHunk approveHunk = setApprove(dbPullId, hunkChecksum, githubUser, statusApproved); + eval(approveHunk); + + ApprovalRecord approvalRecord = db.selectFrom(APPROVAL).fetchOne(); + + assertThat(approvalRecord.getApprover()).isEqualTo(approveHunk.githubUser); + assertThat(approvalRecord.getStatus()).isEqualTo(approveHunk.status.getApproved()); + } + + @Test + public void approveHunk_requiresPullRequest(DSLContext db){ + ApproveHunk create = setApprove(new PullRequestID(424242424242L), hunkChecksum, githubUser, statusApproved); + assertThatExceptionOfType(DatabaseException.class).isThrownBy(() -> eval(create)); + } + + @Test + public void approveHunk_onDuplicate_updatesStatus(DSLContext db){ + ApproveHunk first = setApprove(dbPullId, hunkChecksum, githubUser, statusApproved); + ApproveHunk second = setApprove(dbPullId, hunkChecksum, githubUser, ApproveStatus.DISAPPROVED); + eval(first.then(second)); + + Integer approveCount = db.selectCount().from(APPROVAL).fetchOne().value1(); + assertThat(approveCount).isEqualTo(1); + + List fetchedStatuses = List.ofAll(db.selectFrom(APPROVAL).fetch(APPROVAL.STATUS)) + .map(ApproveStatus::fromString); + + assertThat(fetchedStatuses).hasOnlyOneElementSatisfying( + s -> assertThat(s).isEqualTo(second.status) + ); + } + + @Test + void fetchApprovals_requiresHunkID(DSLContext db){ + FetchHunkApprovals action = fetchApprovals(new HunkID(-1L)); + List result = eval(action); + assertThat(result).isEmpty(); + } + + @Test + void fetchApprovals_returnsAllApprovals(DSLContext db){ + db.insertInto(APPROVAL, APPROVAL.HUNK_ID, APPROVAL.APPROVER, APPROVAL.STATUS) + .values(1L, "txsmith", ApproveStatus.APPROVED.getApproved()) + .values(1L, "eanker", ApproveStatus.DISAPPROVED.getApproved()) + .values(2L, "txsmith", ApproveStatus.DISAPPROVED.getApproved()) + .execute(); + + FetchHunkApprovals action = fetchApprovals(new HunkID(1L)); + List result = eval(action); + assertThat(result).containsOnly( + new HunkApproval(ApproveStatus.APPROVED, "txsmith"), + new HunkApproval(ApproveStatus.DISAPPROVED, "eanker") + ); + } + +} \ No newline at end of file diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java index 7e65926..87f9407 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_GroupTest.java @@ -10,7 +10,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.Assert.assertNull; import static previewcode.backend.database.model.Tables.GROUPS; +import static previewcode.backend.database.model.Tables.HUNK; import static previewcode.backend.database.model.Tables.PULL_REQUEST; import static previewcode.backend.services.actiondsl.ActionDSL.*; import static previewcode.backend.services.actions.DatabaseActions.*; @@ -20,6 +22,7 @@ public class DatabaseInterpreter_GroupTest extends DatabaseInterpreterTest { private static final String groupTitle = "Title"; private static final String groupDescription = "Description"; + private static final Boolean defaultGroup = false; @BeforeEach @Override @@ -32,8 +35,8 @@ public void setup(DSLContext db) { } @Test - public void newGroup_insertsGroup(DSLContext db) throws Exception { - GroupID groupID = eval(newGroup(dbPullId, groupTitle, groupDescription)); + public void newGroup_insertsGroup(DSLContext db) { + GroupID groupID = eval(newGroup(dbPullId, groupTitle, groupDescription, defaultGroup)); assertThat(groupID.id).isPositive(); Integer groupCount = db.selectCount().from(GROUPS).fetchOne().value1(); @@ -41,7 +44,7 @@ public void newGroup_insertsGroup(DSLContext db) throws Exception { } @Test - public void newGroup_returnsNewId(DSLContext db) throws Exception { + public void newGroup_returnsNewId(DSLContext db){ db.insertInto(GROUPS) .columns(GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) .values(dbPullId.id, "A", "B") @@ -50,7 +53,7 @@ public void newGroup_returnsNewId(DSLContext db) throws Exception { .execute(); - GroupID groupID = eval(newGroup(dbPullId, groupTitle, groupDescription)); + GroupID groupID = eval(newGroup(dbPullId, groupTitle, groupDescription, defaultGroup)); GroupID insertedID = new GroupID(db.select(GROUPS.ID).from(GROUPS).where( GROUPS.TITLE.eq(groupTitle).and(GROUPS.DESCRIPTION.eq(groupDescription)) @@ -60,8 +63,8 @@ public void newGroup_returnsNewId(DSLContext db) throws Exception { } @Test - public void newGroup_canInsertDuplicates(DSLContext db) throws Exception { - NewGroup create = newGroup(dbPullId, groupTitle, groupDescription); + public void newGroup_canInsertDuplicates(DSLContext db) { + NewGroup create = newGroup(dbPullId, groupTitle, groupDescription, defaultGroup); eval(create.then(create)); Integer groupCount = db.selectCount().from(GROUPS).fetchOne().value1(); @@ -69,8 +72,8 @@ public void newGroup_canInsertDuplicates(DSLContext db) throws Exception { } @Test - public void newGroup_insertsCorrectData(DSLContext db) throws Exception { - NewGroup create = newGroup(dbPullId, groupTitle, groupDescription); + public void newGroup_insertsCorrectData(DSLContext db) { + NewGroup create = newGroup(dbPullId, groupTitle, groupDescription, defaultGroup); eval(create); GroupsRecord groupsRecord = db.selectFrom(GROUPS).fetchOne(); @@ -78,18 +81,32 @@ public void newGroup_insertsCorrectData(DSLContext db) throws Exception { assertThat(groupsRecord.getPullRequestId()).isEqualTo(create.pullRequestId.id); assertThat(groupsRecord.getTitle()).isEqualTo(create.title); assertThat(groupsRecord.getDescription()).isEqualTo(create.description); + assertThat(groupsRecord.getDefaultGroup()).isEqualTo(null); } + @Test + public void newGroup_insertDefault(DSLContext db) throws Exception { + NewGroup create = newGroup(dbPullId, groupTitle, groupDescription, true); + eval(create); + + GroupsRecord groupsRecord = db.selectFrom(GROUPS).fetchOne(); + + assertThat(groupsRecord.getDefaultGroup().booleanValue()); + assertThat(groupsRecord.getDefaultGroup()).isEqualTo(true); + + } + + @Test public void newGroup_pullRequestMustExist() { PullRequestID wrongID = new PullRequestID(0L); assertThatExceptionOfType(DataAccessException.class) - .isThrownBy(() -> eval(newGroup(wrongID, "A", "B"))); + .isThrownBy(() -> eval(newGroup(wrongID, "A", "B", defaultGroup))); } @Test - public void fetchGroups_returnsAllGroups(DSLContext db) throws Exception { + public void fetchGroups_returnsAllGroups(DSLContext db){ db.insertInto(PULL_REQUEST, PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) .values(dbPullId.id+1, "xyz", "pqr", number) .execute(); @@ -107,14 +124,14 @@ public void fetchGroups_returnsAllGroups(DSLContext db) throws Exception { } @Test - public void fetchGroups_invalidPull_returnsNoResults() throws Exception { + public void fetchGroups_invalidPull_returnsNoResults(){ PullRequestID invalidID = new PullRequestID(-1L); List groups = eval(fetchGroups(invalidID)); assertThat(groups).isEmpty(); } @Test - public void fetchGroups_fetchesCorrectGroupData(DSLContext db) throws Exception { + public void fetchGroups_fetchesCorrectGroupData(DSLContext db){ db.insertInto(GROUPS) .columns(GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) .values(dbPullId.id, "A", "B") @@ -126,13 +143,13 @@ public void fetchGroups_fetchesCorrectGroupData(DSLContext db) throws Exception } @Test - public void fetchGroups_hasHunkFetchingAction(DSLContext db) throws Exception { + public void fetchGroups_hasHunkFetchingAction(DSLContext db){ db.insertInto(GROUPS) .columns(GROUPS.ID, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) .values(1234L, dbPullId.id, "A", "B") .execute(); - Action> hunkFetchAction = eval(fetchGroups(dbPullId)).get(0).fetchHunks; + Action hunkFetchAction = eval(fetchGroups(dbPullId)).get(0).fetchHunks; Interpreter i = interpret() .on(FetchHunksForGroup.class).stop( @@ -141,4 +158,48 @@ public void fetchGroups_hasHunkFetchingAction(DSLContext db) throws Exception { assertThatExceptionOfType(Interpreter.StoppedException.class) .isThrownBy(() -> i.unsafeEvaluate(hunkFetchAction)); } + + @Test + void deleteGroup_groupID_mustExist(DSLContext db){ + db.insertInto(GROUPS) + .columns(GROUPS.ID, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(1234L, dbPullId.id, "A", "B") + .execute(); + + eval(delete(new GroupID(984351L))); + + Integer groupCount = db.selectCount().from(GROUPS).fetchOne().value1(); + assertThat(groupCount).isOne(); + } + + @Test + void deleteGroup_cascades_deletesHunks(DSLContext db){ + db.insertInto(GROUPS) + .columns(GROUPS.ID, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(1234L, dbPullId.id, "A", "B") + .execute(); + + db.insertInto(HUNK) + .columns(HUNK.CHECKSUM, HUNK.GROUP_ID, HUNK.PULL_REQUEST_ID) + .values("abc", 1234L, dbPullId.id) + .execute(); + + eval(delete(new GroupID(1234L))); + + Integer hunkCount = db.selectCount().from(HUNK).fetchOne().value1(); + assertThat(hunkCount).isZero(); + } + + @Test + void deleteGroup_deletesGroup(DSLContext db){ + db.insertInto(GROUPS) + .columns(GROUPS.ID, GROUPS.PULL_REQUEST_ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + .values(1234L, dbPullId.id, "A", "B") + .execute(); + + eval(delete(new GroupID(1234L))); + + Integer groupCount = db.selectCount().from(GROUPS).fetchOne().value1(); + assertThat(groupCount).isZero(); + } } diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java index eff60cf..62e84b9 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java @@ -1,10 +1,15 @@ package previewcode.backend.database; +import io.atlassian.fugue.Unit; import io.vavr.Tuple2; +import io.vavr.collection.List; import org.jooq.DSLContext; import org.jooq.exception.DataAccessException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import previewcode.backend.DTO.HunkChecksum; +import previewcode.backend.services.actiondsl.ActionDSL; +import previewcode.backend.services.actiondsl.ActionDSL.Action; import static org.assertj.core.api.Assertions.*; import static previewcode.backend.database.model.Tables.GROUPS; @@ -14,7 +19,7 @@ public class DatabaseInterpreter_HunksTest extends DatabaseInterpreterTest { - private static final String hunkID = "ABCDEF"; + private static final String hunkChecksum = "ABCDEF"; private static final GroupID group_A_id = new GroupID(0L); private static final GroupID group_B_id = new GroupID(1L); @@ -35,23 +40,23 @@ public void setup(DSLContext db) { } @Test - public void assignHunk_groupMustExist() throws Exception { + public void assignHunk_groupMustExist(){ GroupID invalidID = new GroupID(-1L); - assertThatExceptionOfType(DataAccessException.class) - .isThrownBy(() -> eval(assignToGroup(invalidID, hunkID))); + assertThatExceptionOfType(DatabaseException.class) + .isThrownBy(() -> eval(assignToGroup(invalidID, hunkChecksum))); } @Test public void assignHunk_cannotAssignTwice_toSameGroup() { - AssignHunkToGroup assign = assignToGroup(group_A_id, hunkID); + AssignHunkToGroup assign = assignToGroup(group_A_id, hunkChecksum); assertThatExceptionOfType(DataAccessException.class) .isThrownBy(() -> eval(assign.then(assign))); } @Test - public void assignHunk_insertsIntoHunkTable(DSLContext db) throws Exception { - eval(assignToGroup(group_A_id, hunkID)); + public void assignHunk_insertsIntoHunkTable(DSLContext db){ + eval(assignToGroup(group_A_id, hunkChecksum)); assertThat( db.selectCount().from(HUNK).fetchOneInto(Integer.class) @@ -59,20 +64,39 @@ public void assignHunk_insertsIntoHunkTable(DSLContext db) throws Exception { } @Test - public void assignHunk_canInsertDuplicates(DSLContext db) throws Exception { - eval(assignToGroup(group_A_id, hunkID).then(assignToGroup(group_B_id, hunkID))); + public void assignHunk_cannotAssignTwice_toDifferentGroup(DSLContext db){ + Action assignDouble = assignToGroup(group_A_id, hunkChecksum).then(assignToGroup(group_B_id, hunkChecksum)); + assertThatExceptionOfType(DataAccessException.class) + .isThrownBy(() -> + eval(assignDouble) + ); + } - assertThat( - db.selectCount().from(HUNK).fetchOneInto(Integer.class) - ).isEqualTo(2); + @Test + public void assignHunk_insertsCorrectData(DSLContext db){ + eval(assignToGroup(group_A_id, hunkChecksum)); + + Tuple2 record = db.select(HUNK.GROUP_ID, HUNK.CHECKSUM).from(HUNK).fetchOneInto(Tuple2.class); + assertThat(record).isEqualTo(new Tuple2<>(group_A_id.id, hunkChecksum)); } @Test - public void assignHunk_insertsCorrectData(DSLContext db) throws Exception { - eval(assignToGroup(group_A_id, hunkID)); + void fetchHunks_forUnknownGroup_returnsEmpty(){ + List hunks = eval(fetchHunks(new GroupID(-1L))); + assertThat(hunks).isEmpty(); + } + + @Test + void fetchHunks_returnsAllHunks(DSLContext db){ + db.insertInto(HUNK) + .columns(HUNK.CHECKSUM, HUNK.GROUP_ID, HUNK.PULL_REQUEST_ID) + .values("X", group_A_id.id, dbPullId.id) + .values("Y", group_A_id.id, dbPullId.id) + .values("Z", group_B_id.id, dbPullId.id) + .execute(); - Tuple2 record = db.select(HUNK.GROUP_ID, HUNK.ID).from(HUNK).fetchOneInto(Tuple2.class); - assertThat(record).isEqualTo(new Tuple2(group_A_id.id, hunkID)); + List hunkIDS = eval(fetchHunks(group_A_id)).map(h -> h.checksum); + assertThat(hunkIDS).containsOnly(new HunkChecksum("X"), new HunkChecksum("Y")); } } diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java index ec0ba8c..652f63b 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_PullRequestTest.java @@ -12,7 +12,7 @@ class DatabaseInterpreter_PullRequestTest extends DatabaseInterpreterTest { @Test - public void fetchPull_selectsFromPullsTable(DSLContext db) throws Exception { + public void fetchPull_selectsFromPullsTable(DSLContext db){ db.insertInto(PULL_REQUEST, PULL_REQUEST.ID, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) .values(41L, owner, name, number+1) .values(42L, owner, name, number) @@ -24,7 +24,7 @@ public void fetchPull_selectsFromPullsTable(DSLContext db) throws Exception { } @Test - public void fetchPull_throwsWhenPullIsNotFound() throws Exception { + public void fetchPull_throwsWhenPullIsNotFound(){ PullRequestIdentifier invalidIdentifier = new PullRequestIdentifier("x", "y", 0); assertThatExceptionOfType(DatabaseException.class).isThrownBy( @@ -32,7 +32,7 @@ public void fetchPull_throwsWhenPullIsNotFound() throws Exception { } @Test - public void insertPull_definitelyInserts(DSLContext db) throws Exception { + public void insertPull_definitelyInserts(DSLContext db){ PullRequestID pullRequestID = eval(insertPullIfNotExists(pullIdentifier)); assertThat(pullRequestID.id).isPositive(); @@ -41,7 +41,7 @@ public void insertPull_definitelyInserts(DSLContext db) throws Exception { } @Test - public void insertPull_returnsNewId(DSLContext db) throws Exception { + public void insertPull_returnsNewId(DSLContext db){ db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) .values(owner, name, number+1) .values(owner, name, number+2) @@ -63,7 +63,7 @@ public void insertPull_returnsNewId(DSLContext db) throws Exception { } @Test - public void insertPull_duplicate_doesNotInsert(DSLContext db) throws Exception { + public void insertPull_duplicate_doesNotInsert(DSLContext db){ PullRequestID existingId = new PullRequestID( db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) .values(pullIdentifier.owner, pullIdentifier.name, pullIdentifier.number) @@ -74,7 +74,7 @@ public void insertPull_duplicate_doesNotInsert(DSLContext db) throws Exception { } @Test - public void insertPull_duplicate_returnsOldId(DSLContext db) throws Exception { + public void insertPull_duplicate_returnsOldId(DSLContext db){ PullRequestID existingId = new PullRequestID( db.insertInto(PULL_REQUEST, PULL_REQUEST.OWNER, PULL_REQUEST.NAME, PULL_REQUEST.NUMBER) .values(pullIdentifier.owner, pullIdentifier.name, pullIdentifier.number) diff --git a/src/test/java/previewcode/backend/database/SchemaTest.java b/src/test/java/previewcode/backend/database/SchemaTest.java index 61d86ed..8a01016 100644 --- a/src/test/java/previewcode/backend/database/SchemaTest.java +++ b/src/test/java/previewcode/backend/database/SchemaTest.java @@ -25,7 +25,7 @@ public void pullRequestTable_isEmpty(DSLContext db) { @Test public void groupsTable_isEmpty(DSLContext db) { - int rows = db.select(GROUPS.ID, GROUPS.TITLE, GROUPS.DESCRIPTION) + int rows = db.select(GROUPS.ID, GROUPS.TITLE, GROUPS.DESCRIPTION, GROUPS.DEFAULT_GROUP) .from(GROUPS).execute(); assertThat(rows).isZero(); } @@ -37,6 +37,14 @@ public void hunksTable_isEmpty(DSLContext db) { assertThat(rows).isZero(); } + @Test + public void approvalsTable_isEmpty(DSLContext db) { + int rows = db.select(APPROVAL.HUNK_ID, APPROVAL.APPROVER, APPROVAL.STATUS) + .from(APPROVAL).execute(); + + assertThat(rows).isZero(); + } + @Test public void hasSequence_pullRequestId(DSLContext db) { db.nextval(SEQ_PK_PULL_REQUEST); diff --git a/src/test/java/previewcode/backend/github/GitHubAuthInterpreterTest.java b/src/test/java/previewcode/backend/github/GitHubAuthInterpreterTest.java new file mode 100644 index 0000000..3d0aa0b --- /dev/null +++ b/src/test/java/previewcode/backend/github/GitHubAuthInterpreterTest.java @@ -0,0 +1,326 @@ +package previewcode.backend.github; + +import io.atlassian.fugue.Unit; +import org.apache.commons.codec.binary.Hex; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import previewcode.backend.DTO.GitHubUser; +import previewcode.backend.DTO.GitHubUserToken; +import previewcode.backend.DTO.InstallationID; +import previewcode.backend.api.exceptionmapper.NotAuthorizedException; +import previewcode.backend.api.filter.IJWTTokenCreator; +import previewcode.backend.services.actiondsl.ActionCache; +import previewcode.backend.services.actiondsl.ActionDSL; +import previewcode.backend.services.actiondsl.Interpreter; +import previewcode.backend.services.http.IHttpRequestExecutor; +import previewcode.backend.services.interpreters.GitHubAuthInterpreter; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static previewcode.backend.services.actiondsl.ActionDSL.Action; +import static previewcode.backend.services.actions.GitHubActions.*; + +public class GitHubAuthInterpreterTest { + + static final SecretKeySpec sharedSecret = new SecretKeySpec("very-secret".getBytes(), "HmacSHA1"); + static final String exampleBody = "Hello World!"; + static final String headerPrefix = "sha1="; + static final String integrationId = "123456789"; + static final InstallationID installationID = new InstallationID("987654321"); + static String expectedSHA1; + + IHttpRequestExecutor defaultHttpExec = r -> { + throw new RuntimeException("Cannot fire requests in a test"); + }; + + IJWTTokenCreator jwtCreator = integrationId -> integrationId + "token"; + + ActionCache cache = new ActionCache.Builder().maximumEntries(0).build(); + + class GitHubInterpreter extends GitHubAuthInterpreter { + public GitHubInterpreter(String integrationId, SecretKeySpec sharedSecret, IJWTTokenCreator jwtTokenCreator, IHttpRequestExecutor http, ActionCache cache) { + super(integrationId, sharedSecret, jwtTokenCreator, http, cache); + } + + @Override + protected void authViaOldApi(GetUser action) throws IOException { + // Do nothing + } + + @Override + protected void registerUserToken(String token) { + // Do nothing + } + } + + Interpreter ghInterpreter = new GitHubInterpreter(integrationId, sharedSecret, jwtCreator, defaultHttpExec, cache); + + @BeforeAll + static void setup() throws NoSuchAlgorithmException, InvalidKeyException { + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(sharedSecret); + expectedSHA1 = Hex.encodeHexString(mac.doFinal(exampleBody.getBytes())); + } + + @Test + void isWebHook_succeedsForCorrectString(){ + assertThat(eval(isWebHookUserAgent("GitHub-Hookshot/"))) + .isTrue(); + } + + @Test + void isWebHook_failsForEmptyString(){ + assertThat(eval(isWebHookUserAgent(""))) + .isFalse(); + } + + @Test + void isWebHook_failsForNull(){ + assertThat(eval(isWebHookUserAgent(null))) + .isFalse(); + } + + @Test + void isWebHook_failsForSimilarStrings(){ + assertThat(eval(isWebHookUserAgent("GitHub-Hookshot"))) + .isFalse(); + + assertThat(eval(isWebHookUserAgent("GitHub-Hookshot\\"))) + .isFalse(); + + assertThat(eval(isWebHookUserAgent("Github-Hookshot/"))) + .isFalse(); + + assertThat(eval(isWebHookUserAgent("Hookshot/"))) + .isFalse(); + + assertThat(eval(isWebHookUserAgent("GitHub"))) + .isFalse(); + + assertThat(eval(isWebHookUserAgent("-"))) + .isFalse(); + + assertThat(eval(isWebHookUserAgent("/"))) + .isFalse(); + } + + @Test + void verifySecret_succeedsForCorrectParameters(){ + Action action = verifyWebHookSecret(exampleBody, headerPrefix + expectedSHA1); + eval(action); + } + + @Test + void verifySecret_failsWhenChanging_requestBody() { + Action action = verifyWebHookSecret(exampleBody+".", headerPrefix + expectedSHA1); + + assertThatExceptionOfType(NotAuthorizedException.class) + .isThrownBy(() -> eval(action)); + } + + @Test + void verifySecret_failsWhenChanging_receivedSHA() { + Action action = verifyWebHookSecret(exampleBody, headerPrefix + expectedSHA1+"a"); + + assertThatExceptionOfType(NotAuthorizedException.class) + .isThrownBy(() -> eval(action)); + } + + @Test + void verifySecret_failWhenChanging_storedKey() throws NoSuchAlgorithmException, InvalidKeyException { + SecretKeySpec newKey = new SecretKeySpec("very-secret!".getBytes(), "HmacSHA1"); + ghInterpreter = new GitHubInterpreter(integrationId, newKey, jwtCreator, defaultHttpExec, cache); + + Action action = verifyWebHookSecret(exampleBody, headerPrefix + expectedSHA1); + + assertThatExceptionOfType(NotAuthorizedException.class) + .isThrownBy(() -> eval(action)); + } + + @Test + void verifySecret_shaMustBePrefixed() { + Action action = verifyWebHookSecret(exampleBody, expectedSHA1); + + assertThatExceptionOfType(NotAuthorizedException.class) + .isThrownBy(() -> eval(action)); + } + + + @Test + void getUser_callsGitHub_withAuthHeader() { + String userToken = "328943724"; + Action action = getUser(new GitHubUserToken(userToken)); + GitHubInterpreter interpreter = new GitHubInterpreter(null, null, null, + request -> { + assertThat(request.header("Authorization")).isEqualTo("token " + userToken); + throw new DoneException(); + }, cache); + + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void getUser_callsGitHub_userEndpoint() { + Action action = getUser(new GitHubUserToken("")); + GitHubInterpreter interpreter = new GitHubInterpreter(null, null, null, + request -> { + assertThat(request.url().pathSegments()).containsExactly("user"); + throw new DoneException(); + }, cache); + + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void getUser_callsGitHub_withHTTPS() { + Action action = getUser(new GitHubUserToken("")); + GitHubInterpreter interpreter = new GitHubInterpreter(null, null, null, + request -> { + assertThat(request.isHttps()).isTrue(); + throw new DoneException(); + }, cache); + + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void getUser_callsGitHub_onCorrectHost() { + Action action = getUser(new GitHubUserToken("")); + GitHubInterpreter interpreter = new GitHubInterpreter(null, null, null, + request -> { + assertThat(request.url().host()).isEqualTo("api.github.com"); + throw new DoneException(); + }, cache); + + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void getUser_callsGitHub_methodGET() { + Action action = getUser(new GitHubUserToken("")); + GitHubInterpreter interpreter = new GitHubInterpreter(null, null, null, + request -> { + assertThat(request.method()).isEqualTo("GET"); + throw new DoneException(); + }, cache); + + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void getUser_parses_idAndLogin(){ + String exampleUserResponse = "{\n" + + " \"login\": \"octocat\",\n" + + " \"id\": 1\n" + + "}"; + + + Action action = getUser(new GitHubUserToken("")); + GitHubInterpreter interpreter = new GitHubInterpreter(null, null, null, + request -> exampleUserResponse, cache); + + GitHubUser parsedResponse = interpreter.unsafeEvaluate(action); + assertThat(parsedResponse.id).isEqualTo(1); + assertThat(parsedResponse.login).isEqualTo("octocat"); + } + + @Test + void authInstallation_usesJWT() { + GitHubInterpreter interpreter = new GitHubInterpreter(integrationId, null, i -> { + assertThat(i).isEqualTo(integrationId); + throw new DoneException(); + }, null, cache); + + Action action = authenticateInstallation(installationID); + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void authInstallation_callsGitHub_withPOST() { + + GitHubInterpreter interpreter = new GitHubInterpreter(integrationId, null, jwtCreator, + request -> { + assertThat(request.method()).isEqualTo("POST"); + throw new DoneException(); + }, cache); + + Action action = authenticateInstallation(installationID); + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void authInstallation_callsGitHub_withHTTPS() { + + GitHubInterpreter interpreter = new GitHubInterpreter(integrationId, null, jwtCreator, + request -> { + assertThat(request.isHttps()).isTrue(); + throw new DoneException(); + }, cache); + + Action action = authenticateInstallation(installationID); + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void authInstallation_callsGitHub_withCorrectPath() { + + GitHubInterpreter interpreter = new GitHubInterpreter(integrationId, null, jwtCreator, + request -> { + assertThat(request.url().pathSegments()) + .containsExactly("installations", installationID.id, "access_tokens"); + throw new DoneException(); + }, cache); + + Action action = authenticateInstallation(installationID); + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void authInstallation_callsGitHub_withAcceptHeader() { + + GitHubInterpreter interpreter = new GitHubInterpreter(integrationId, null, jwtCreator, + request -> { + assertThat(request.header("Accept")).isEqualTo("application/vnd.github.machine-man-preview+json"); + throw new DoneException(); + }, cache); + + Action action = authenticateInstallation(installationID); + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + @Test + void authInstallation_callsGitHub_withAuthorizationHeader() { + + GitHubInterpreter interpreter = new GitHubInterpreter(integrationId, null, jwtCreator, + request -> { + assertThat(request.header("Authorization")).isEqualTo("Bearer " + jwtCreator.create(integrationId)); + throw new DoneException(); + }, cache); + + Action action = authenticateInstallation(installationID); + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(action)); + } + + class DoneException extends RuntimeException {} + + protected T eval(ActionDSL.Action action){ + return ghInterpreter.unsafeEvaluate(action); + } +} diff --git a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java index 96b5c95..486fb77 100644 --- a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java +++ b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java @@ -6,16 +6,16 @@ import io.vavr.control.Option; import org.junit.jupiter.api.Test; import previewcode.backend.DTO.*; -import previewcode.backend.database.GroupID; -import previewcode.backend.database.HunkID; -import previewcode.backend.database.PullRequestGroup; -import previewcode.backend.database.PullRequestID; +import previewcode.backend.database.*; +import previewcode.backend.DTO.HunkChecksum; import previewcode.backend.services.actiondsl.Interpreter; import java.util.Collection; import java.util.function.Consumer; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.Assert.fail; import static previewcode.backend.services.actiondsl.ActionDSL.*; import static previewcode.backend.services.actions.DatabaseActions.*; @@ -32,27 +32,28 @@ public class DatabaseServiceTest { private final PullRequestID pullRequestID = new PullRequestID(new Long(number)); private PullRequestIdentifier pullIdentifier = new PullRequestIdentifier(owner, name, number); + private Boolean defaultGroup = false; private List groups = List.of( - new PullRequestGroup(new GroupID(42L), "Group A", "Description A"), - new PullRequestGroup(new GroupID(24L), "Group B", "Description B") + new PullRequestGroup(new GroupID(42L), "Group A", "Description A", defaultGroup), + new PullRequestGroup(new GroupID(24L), "Group B", "Description B", defaultGroup) ); private List groupsWithoutHunks= groups.map(group -> - new OrderingGroupWithID(group, Lists.newLinkedList()) + new OrderingGroupWithID(group, Lists.newLinkedList()) ); - private List hunkIDs = List.of( - new HunkID("abcd"), new HunkID("efgh"), new HunkID("ijkl")); + private List hunkIDs = List.of( + new HunkChecksum("abcd"), new HunkChecksum("efgh"), new HunkChecksum("ijkl")); private List groupsWithHunks = groups.map(group -> - new OrderingGroupWithID(group, hunkIDs.map(id -> id.hunkID).toJavaList()) + new OrderingGroupWithID(group, hunkIDs.map(id -> id).toJavaList()) ); - + private ApproveRequest approveStatus = new ApproveRequest("checksum", ApproveStatus.DISAPPROVED, "txsmith"); @Test - public void insertsPullIfNotExists() throws Exception { + public void insertsPullIfNotExists(){ Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); Consumer assertions = action -> { @@ -69,16 +70,16 @@ public void insertsPullIfNotExists() throws Exception { } @Test - public void removesExistingGroups() throws Exception { + public void removesExistingGroups(){ Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); Collection removedGroups = Lists.newArrayList(); Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(groups) - .on(DeleteGroup.class).apply(toUnit(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(DeleteGroup.class).apply(toUnit(action -> { assertThat(groups).extracting("id").contains(action.groupID); removedGroups.add(groups.find(group -> group.id.equals(action.groupID)).get()); })); @@ -90,28 +91,28 @@ public void removesExistingGroups() throws Exception { } @Test - public void doesNotRemoveGroups() throws Exception { + public void doesNotRemoveGroups(){ Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(List.empty()); + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()); interpreter.unsafeEvaluate(dbAction); } @Test - public void insertsNewGroupsWithoutHunks() throws Exception { + public void insertsNewGroupsWithoutHunks(){ Action dbAction = service.updateOrdering(pullIdentifier, groupsWithoutHunks); Collection groupsAdded = Lists.newArrayList(); Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(List.empty()) - .on(NewGroup.class).apply(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()) + .on(NewGroup.class).apply(action -> { assertThat(action.pullRequestId).isEqualTo(pullRequestID); PullRequestGroup group = groups.find(g -> g.title.equals(action.title)).get(); assertThat(group.description).isEqualTo(action.description); @@ -126,20 +127,20 @@ public void insertsNewGroupsWithoutHunks() throws Exception { } @Test - public void insertsNewGroupsWithHunks() throws Exception { + public void insertsNewGroupsWithHunks(){ Action dbAction = service.updateOrdering(pullIdentifier, groupsWithHunks); - Collection hunksAdded = Lists.newArrayList(); + Collection hunksAdded = Lists.newArrayList(); Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(List.empty()) - .on(NewGroup.class).apply(action -> - groups.find(g -> g.title.equals(action.title)).get().id) - .on(AssignHunkToGroup.class).apply(toUnit(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()) + .on(NewGroup.class).apply(action -> + groups.find(g -> g.title.equals(action.title)).get().id) + .on(AssignHunkToGroup.class).apply(toUnit(action -> { assertThat(groups.find(g -> g.id.equals(action.groupID))).isNotEmpty(); - Option hunkID = hunkIDs.find(id -> id.hunkID.equals(action.hunkIdentifier)); + Option hunkID = hunkIDs.find(id -> id.checksum.equals(action.hunkChecksum)); assertThat(hunkID).isNotEmpty(); hunksAdded.add(hunkID.get()); })); @@ -149,4 +150,191 @@ public void insertsNewGroupsWithHunks() throws Exception { .hasSameElementsAs(hunkIDs) .hasSize(hunkIDs.size() * groupsWithHunks.size()); } + + @Test + public void insertsDefaultGroup() throws Exception { + PullRequestGroup group = new PullRequestGroup(new GroupID(42L), "Group A", "Description A", true); + OrderingGroup defaultGroup = new OrderingGroupWithID(group, hunkIDs.map(id -> id).toJavaList()); + Action dbAction = service.insertDefaultGroup(pullIdentifier, defaultGroup); + + Collection groupsAdded = Lists.newArrayList(); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(NewGroup.class).apply(action -> { + assertThat(action.defaultGroup).isEqualTo(true); + groupsAdded.add(group); + return group.id; + }) + .on(AssignHunkToGroup.class).apply(toUnit(action -> { + assertThat(List.of(group).find(g -> g.id.equals(action.groupID))).isNotEmpty(); + Option hunkID = hunkIDs.find(id -> id.checksum.equals(action.hunkChecksum)); + assertThat(hunkID).isNotEmpty(); + })); + + interpreter.unsafeEvaluate(dbAction); + assertThat(groupsAdded) + .hasSameElementsAs(List.of(group)) + .hasSameSizeAs(List.of(group)); + } + + @Test + public void insertApproval() { + Action dbAction = service.setApproval(pullIdentifier, approveStatus); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(ApproveHunk.class).stop(approveHunk -> { + assertThat(approveHunk.status) + .isEqualTo(ApproveStatus.DISAPPROVED); + assertThat(approveHunk.githubUser) + .isEqualTo("txsmith"); + assertThat(approveHunk.hunkChecksum) + .isEqualTo("checksum"); + assertThat(approveHunk.pullRequestID) + .isEqualTo(pullRequestID); + }); + + assertThatExceptionOfType(Interpreter.StoppedException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(dbAction)); + + } + + + @Test + void getApproval_fetches_pull_pullRequest() { + Action dbAction = service.getApproval(pullIdentifier); + + Interpreter.Stepper stepper = interpret().stepwiseEval(dbAction); + List> peek = stepper.peek(); + assertThat(peek).containsOnly(fetchPull(pullIdentifier)); + } + + @Test + void getApproval_fetches_pull_groups(){ + Action dbAction = service.getApproval(pullIdentifier); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .stepwiseEval(dbAction); + List> next = stepper.next(); + assertThat(next).containsOnly(fetchGroups(pullRequestID)); + } + + @Test + void getApproval_fetches_hunks(){ + Action dbAction = service.getApproval(pullIdentifier); + + List oneGroup = List.of( + new PullRequestGroup(new GroupID(42L), "Group A", "Description A", defaultGroup) + ); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(oneGroup) + .stepwiseEval(dbAction); + stepper.next(); + List> next = stepper.next(); + assertThat(next).containsOnly(fetchHunks(oneGroup.head().id)); + } + + @Test + void getApproval_fetches_hunk_approvals(){ + Action dbAction = service.getApproval(pullIdentifier); + + HunkChecksum checksum = new HunkChecksum("abcd"); + HunkID id = new HunkID(1L); + List oneHunk = List.of(new Hunk(id, new GroupID(2L), checksum)); + + List hunkApprovals = List.of(new HunkApproval(ApproveStatus.APPROVED, "txsmith")); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(FetchHunksForGroup.class).returnA(oneHunk) + .on(FetchHunkApprovals.class).returnA(hunkApprovals) + .stepwiseEval(dbAction); + stepper.next(); + stepper.next(); + + assertThat(stepper.next()).containsOnly(new FetchHunkApprovals(id)); + assertThat(stepper.next()).isEmpty(); + } + + @Test + void getHunkApproval_fetches_pull_pullRequest() { + Action dbAction = service.getHunkApprovals(pullIdentifier); + + List> peek = interpret().stepwiseEval(dbAction).peek(); + assertThat(peek).containsOnly(fetchPull(pullIdentifier)); + } + + @Test + void getHunkApproval_fetches_pull_groups(){ + Action dbAction = service.getHunkApprovals(pullIdentifier); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .stepwiseEval(dbAction); + List> next = stepper.next(); + assertThat(next).containsOnly(fetchGroups(pullRequestID)); + } + + @Test + void getHunkApproval_fetches_hunks(){ + Action dbAction = service.getHunkApprovals(pullIdentifier); + + List oneGroup = List.of( + new PullRequestGroup(new GroupID(42L), "Group A", "Description A", defaultGroup) + ); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(oneGroup) + .stepwiseEval(dbAction); + stepper.next(); + List> next = stepper.next(); + assertThat(next).containsOnly(fetchHunks(oneGroup.head().id)); + } + + @Test + void getHunkApproval_fetches_hunk_approvals(){ + Action dbAction = service.getHunkApprovals(pullIdentifier); + + HunkChecksum checksum = new HunkChecksum("abcd"); + HunkID id = new HunkID(1L); + List oneHunk = List.of(new Hunk(id, new GroupID(2L), checksum)); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(FetchHunksForGroup.class).returnA(oneHunk) + .stepwiseEval(dbAction); + stepper.next(); + stepper.next(); + + List> next = stepper.next(); + assertThat(next.length()).isEqualTo(2); + assertThat(next).containsOnly(fetchApprovals(id)); + } + + @Test + void getHunkApproval_no_action_after_fetching_hunkapprovals(){ + Action dbAction = service.getHunkApprovals(pullIdentifier); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(FetchHunksForGroup.class).returnA(hunkIDs.map(id -> new Hunk(new HunkID(-1L), new GroupID(2L), id))) + .on(FetchHunkApprovals.class).returnA(List.empty()) + .stepwiseEval(dbAction); + stepper.next(); + stepper.next(); + stepper.next(); + + List> next = stepper.next(); + assertThat(next).isEmpty(); + } } diff --git a/src/test/java/previewcode/backend/services/GitHubServiceTest.java b/src/test/java/previewcode/backend/services/GitHubServiceTest.java new file mode 100644 index 0000000..36b43e2 --- /dev/null +++ b/src/test/java/previewcode/backend/services/GitHubServiceTest.java @@ -0,0 +1,141 @@ +package previewcode.backend.services; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.atlassian.fugue.Unit; +import io.vavr.control.Option; +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.Test; +import previewcode.backend.DTO.InstallationID; +import previewcode.backend.api.exceptionmapper.NoTokenException; +import previewcode.backend.services.actiondsl.Interpreter; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.*; +import static previewcode.backend.services.actiondsl.ActionDSL.*; +import static previewcode.backend.services.actions.GitHubActions.*; +import static previewcode.backend.services.actions.RequestContextActions.*; + +public class GitHubServiceTest { + + GithubService.V2 ghService = new GithubService.V2(); + + Action authAction = ghService.authenticate(); + + + + @Test + void authenticate_checksUserAgent() { + assertStopped(() -> interpret() + .on(GetUserAgent.class).stop() + .unsafeEvaluate(authAction)); + } + + @Test + void authenticate_nonWebHook_checksQueryParam(){ + assertStopped(() -> interpret() + .on(GetUserAgent.class).returnA("non-webhook-user-agent") + .on(IsWebHookUserAgent.class).returnA(false) + .on(GetQueryParam.class).stop(action -> assertThat(action.param).isEqualTo("access_token")) + .unsafeEvaluate(authAction) + ); + } + + @Test + void authenticate_nonWebHook_throwsNoTokenException() { + assertThatExceptionOfType(NoTokenException.class) + .isThrownBy(() -> interpret() + .on(GetUserAgent.class).returnA("non-webhook-user-agent") + .on(IsWebHookUserAgent.class).returnA(false) + .on(GetQueryParam.class).returnA(Option.none()) + .unsafeEvaluate(authAction) + ); + } + + @Test + void authenticate_nonWebHook_fetchesGitHubUser_withToken() { + assertStopped(() -> interpret() + .on(GetUserAgent.class).returnA("non-webhook-user-agent") + .on(IsWebHookUserAgent.class).returnA(false) + .on(GetQueryParam.class).returnA(Option.of("someToken123")) + .on(GetUser.class).stop(fetchAction -> assertThat(fetchAction.token.token).isEqualTo("someToken123")) + .unsafeEvaluate(authAction) + ); + } + + @Test + void authenticate_webhook_checksUserAgent(){ + Interpreter.Stepper stepper = interpret() + .on(GetUserAgent.class).returnA("some-user-agent") + .on(IsWebHookUserAgent.class).returnA(true) + .stepwiseEval(authAction); + assertThat(stepper.next()).containsOnly(isWebHookUserAgent("some-user-agent")); + } + + @Test + void authenticate_webhook_readsRequestBody_andHeader(){ + Interpreter.Stepper stepper = interpret() + .on(GetUserAgent.class).returnA("GitHub-Hookshot/") + .on(IsWebHookUserAgent.class).returnA(true) + .stepwiseEval(authAction); + stepper.next(); + assertThat(stepper.next()).containsOnly(getRequestBody, getHeader("X-Hub-Signature")); + } + + @Test + void authenticate_webhook_verifiesSignature(){ + Interpreter.Stepper stepper = interpret() + .on(GetUserAgent.class).returnA("GitHub-Hookshot/") + .on(IsWebHookUserAgent.class).returnA(true) + .on(GetRequestBody.class).returnA("somebody") + .on(GetHeader.class).returnA("signature") + .stepwiseEval(authAction); + + stepper.next(); + stepper.next(); + assertThat(stepper.next()).containsOnly(verifyWebHookSecret("somebody", "signature")); + } + + @Test + void authenticate_webhook_fetchesInstallationID(){ + Interpreter.Stepper stepper = interpret() + .on(GetUserAgent.class).returnA("GitHub-Hookshot/") + .on(IsWebHookUserAgent.class).returnA(true) + .on(GetRequestBody.class).returnA("somebody") + .on(GetHeader.class).returnA("signature") + .ignore(VerifyWebhookSharedSecret.class) + .stepwiseEval(authAction); + + stepper.next(); + stepper.next(); + stepper.next(); + assertThat(stepper.next()).containsOnly(getJsonBody); + } + + @Test + void authenticate_webhook_authenticatesInstallation() throws IOException { + JsonNode json = new ObjectMapper().readTree("{ \"installation\": { \"id\": 1234 } }"); + + Interpreter.Stepper stepper = interpret() + .on(GetUserAgent.class).returnA("GitHub-Hookshot/") + .on(IsWebHookUserAgent.class).returnA(true) + .on(GetRequestBody.class).returnA("somebody") + .on(GetHeader.class).returnA("signature") + .ignore(VerifyWebhookSharedSecret.class) + .on(GetJsonRequestBody.class).returnA(json) + .stepwiseEval(authAction); + + stepper.next(); + stepper.next(); + stepper.next(); + stepper.next(); + assertThat(stepper.next()).containsOnly(authenticateInstallation(new InstallationID("1234"))); + } + + + + void assertStopped(ThrowableAssert.ThrowingCallable t) { + assertThatExceptionOfType(Interpreter.StoppedException.class).isThrownBy(t); + } +} diff --git a/src/test/java/previewcode/backend/services/actiondsl/CachingInterpreterTest.java b/src/test/java/previewcode/backend/services/actiondsl/CachingInterpreterTest.java new file mode 100644 index 0000000..15093ba --- /dev/null +++ b/src/test/java/previewcode/backend/services/actiondsl/CachingInterpreterTest.java @@ -0,0 +1,152 @@ +package previewcode.backend.services.actiondsl; + + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.testing.FakeTicker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.*; +import static previewcode.backend.services.actiondsl.ActionDSL.*; + + +class ActionCacheTest { + + class O extends Action { + @Override + public boolean equals(Object obj) { + return obj.getClass().equals(this.getClass()); + } + + @Override + public int hashCode() { + return this.getClass().hashCode(); + } + } + class X extends O {} + class Y extends O {} + class Z extends O {} + + + class CacheInterpreter extends CachingInterpreter { + + public CacheInterpreter(ActionCache cache) { + super(cache); + on(X.class).returnA(1); + on(Y.class).returnA(2); + on(Z.class).returnA(3); + } + } + + FakeTicker ticker; + + @BeforeEach + void setup() { + ticker = new FakeTicker(); + } + + private CacheInterpreter createDefaultInterpreter() { + return new CacheInterpreter(new ActionCache.Builder() + .expire(X.class).afterWrite(1, TimeUnit.SECONDS) + .maximumEntries(10) + .fromCaffeineConfig(Caffeine.newBuilder().ticker(ticker::read)) + .build() + ); + } + + private CacheInterpreter createAdvancedInterpreter(){ + + CacheInterpreter interpreter = new CacheInterpreter(new ActionCache.Builder() + .expire(X.class).afterWrite(1, TimeUnit.MINUTES) + .expire(Y.class).afterWrite(1, TimeUnit.HOURS) + .maximumEntries(10) + .fromCaffeineConfig(Caffeine.newBuilder().ticker(ticker::read)) + .build() + ); + + // X will be cached for 1 minute + interpreter.unsafeEvaluate(new X()); + // Y will be cached for 1 hour + interpreter.unsafeEvaluate(new Y()); + // Z will NOT be cached + interpreter.unsafeEvaluate(new Z()); + + // When expired, evaluating any action will throw + interpreter.on(X.class).stop(); + interpreter.on(Y.class).stop(); + interpreter.on(Z.class).stop(); + return interpreter; + } + + + @Test + void cachesResults(){ + CacheInterpreter interpreter = createDefaultInterpreter(); + interpreter.unsafeEvaluate(new X()); + assertThat(interpreter.getCache().getIfPresent(new X())).isEqualTo(1); + } + + @Test + void readsFromCache(){ + CacheInterpreter interpreter = createDefaultInterpreter(); + + // Caches the first result: 1 + interpreter.unsafeEvaluate(new X()); + interpreter.on(X.class).stop(); + // The cache should now return 1 without calling the interpreter + assertThat(interpreter.unsafeEvaluate(new X())).isEqualTo(1); + assertThat(interpreter.getCache().getIfPresent(new X())).isEqualTo(1); + } + + @Test + void doesNotCache_unconfiguredItems(){ + CacheInterpreter interpreter = createDefaultInterpreter(); + + interpreter.unsafeEvaluate(new Y()); + + // Throw on next call to the interpreter + interpreter.on(Y.class).stop(); + + // Cache miss causes interpreter to be run + assertThat(interpreter.getCache().getIfPresent(new Y())).isNull(); + assertThatExceptionOfType(Interpreter.StoppedException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(new Y())); + } + + @Test + void doesNotEvict_beforeSpecificTime_beforeDefaultTime(){ + CacheInterpreter interpreter = createAdvancedInterpreter(); + ticker.advance(59, TimeUnit.SECONDS); + assertThat(interpreter.getCache().getIfPresent(new X())).isEqualTo(1); + assertThat(interpreter.unsafeEvaluate(new X())).isEqualTo(1); + } + + @Test + void evicts_afterSpecificTime_beforeDefaultTime(){ + CacheInterpreter interpreter = createAdvancedInterpreter(); + ticker.advance(61, TimeUnit.SECONDS); + assertThat(interpreter.getCache().getIfPresent(new X())).isNull(); + assertThatExceptionOfType(Interpreter.StoppedException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(new X())); + } + + @Test + void doesNotEvict_beforeSpecificTime_afterDefaultTime(){ + CacheInterpreter interpreter = createAdvancedInterpreter(); + ticker.advance(59, TimeUnit.MINUTES); + assertThat(interpreter.getCache().getIfPresent(new Y())).isEqualTo(2); + assertThat(interpreter.unsafeEvaluate(new Y())).isEqualTo(2); + } + + @Test + void evicts_afterSpecificTime_afterDefaultTime(){ + CacheInterpreter interpreter = createAdvancedInterpreter(); + ticker.advance(61, TimeUnit.MINUTES); + assertThat(interpreter.getCache().getIfPresent(new Y())).isNull(); + assertThatExceptionOfType(Interpreter.StoppedException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(new Y())); + } + +} \ No newline at end of file diff --git a/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java b/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java index 8393192..53ee695 100644 --- a/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java +++ b/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java @@ -5,8 +5,6 @@ import org.jooq.conf.Settings; import org.jooq.impl.DSL; import org.junit.jupiter.api.extension.*; -import org.junit.jupiter.api.extension.ExtensionContext.Namespace; -import org.junit.jupiter.api.extension.ExtensionContext.Store; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import previewcode.backend.database.model.DefaultCatalog; @@ -14,13 +12,14 @@ import java.sql.DriverManager; import java.sql.SQLException; -public class DatabaseTestExtension implements ParameterResolver, AfterEachCallback { +public class DatabaseTestExtension extends TestStore implements ParameterResolver, AfterEachCallback { private static final Logger logger = LoggerFactory.getLogger(DatabaseTestExtension.class); private static final String userName = "admin"; private static final String password = "password"; private static final String url = "jdbc:postgresql://localhost:5432/preview_code"; + @Override public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { Class parameterType = parameterContext.getParameter().getType(); @@ -35,7 +34,7 @@ public Object resolve(ParameterContext parameterContext, ExtensionContext extens logger.debug("Obtaining database connection from DriverManager..."); try { DSLContext dslContext = DSL.using(DriverManager.getConnection(url, userName, password), SQLDialect.POSTGRES_9_5, settings); - putDslContext(extensionContext, dslContext); + putObjectToStore(extensionContext, dslContext); return dslContext; } catch (SQLException e) { throw new RuntimeException(e); @@ -44,7 +43,7 @@ public Object resolve(ParameterContext parameterContext, ExtensionContext extens @Override public void afterEach(TestExtensionContext context) throws Exception { - DSLContext db = getDslContext(context); + DSLContext db = getFromStore(context); if (db != null) { logger.debug("Commence database cleanup."); DefaultCatalog.DEFAULT_CATALOG.getSchemas().forEach(schema -> { @@ -62,20 +61,4 @@ public void afterEach(TestExtensionContext context) throws Exception { logger.debug("Database truncated."); } } - - private DSLContext getDslContext(ExtensionContext context) { - return getStore(context).get(getStoreKey(context), DSLContext.class); - } - - private void putDslContext(ExtensionContext context, DSLContext db) { - getStore(context).put(getStoreKey(context), db); - } - - private Object getStoreKey(ExtensionContext context) { - return context.getTestMethod().get(); - } - - private Store getStore(ExtensionContext context) { - return context.getStore(Namespace.create(getClass(), context)); - } } diff --git a/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java b/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java index 9bc3e2c..0572967 100644 --- a/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java +++ b/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java @@ -4,6 +4,7 @@ import com.google.inject.Injector; import com.google.inject.servlet.GuiceFilter; import com.google.inject.servlet.ServletModule; +import io.vavr.Tuple2; import io.vavr.control.Try; import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; @@ -19,19 +20,15 @@ import javax.ws.rs.client.WebTarget; -public class GuiceResteasyExtension extends AnnotatedClassInstantiator implements BeforeAllCallback, AfterAllCallback, ParameterResolver { +public class GuiceResteasyExtension extends TestStore> implements BeforeAllCallback, AfterAllCallback, ParameterResolver { - private Server server; - private ResteasyClient client; @Override - public void beforeAll(ContainerExtensionContext containerExtensionContext) throws Exception { + public void beforeAll(ContainerExtensionContext context) throws Exception { - ServletModule guiceModule = getServletModule(containerExtensionContext); + ServletModule guiceModule = getServletModule(context); - client = new ResteasyClientBuilder().build(); - - server = new Server(); + Server server = new Server(); ServerConnector httpConnector = new ServerConnector(server, new HttpConnectionFactory()); httpConnector.setHost("localhost"); httpConnector.setPort(0); @@ -48,6 +45,8 @@ public void beforeAll(ContainerExtensionContext containerExtensionContext) throw server.setHandler(servletHandler); server.start(); + + putObjectToStore(context, new Tuple2<>(new ResteasyClientBuilder().build(), server)); } private ServletModule getServletModule(ContainerExtensionContext context) { @@ -56,16 +55,12 @@ private ServletModule getServletModule(ContainerExtensionContext context) { .get(); } - private Try> getAnnotatedModule(Class testClass) { - if (testClass.isAnnotationPresent(ApiEndPointTest.class)) - return Try.success(testClass.getAnnotation(ApiEndPointTest.class).value()); - else - return Try.failure(new RuntimeException("GuiceResteasyExtension can only be used via the ApiEndPointTest annotation.")); - } - @Override - public void afterAll(ContainerExtensionContext containerExtensionContext) throws Exception { + public void afterAll(ContainerExtensionContext context) throws Exception { + Tuple2 t = getFromStore(context); + ResteasyClient client = t._1; + Server server = t._2; if (client != null && server != null) { client.close(); server.stop(); @@ -79,7 +74,11 @@ public boolean supports(ParameterContext parameterContext, ExtensionContext exte @Override public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + Tuple2 t = getFromStore(extensionContext.getParent().get()); + ResteasyClient client = t._1; + Server server = t._2; return client.target(server.getURI()); } + } diff --git a/src/test/java/previewcode/backend/test/helpers/TestStore.java b/src/test/java/previewcode/backend/test/helpers/TestStore.java new file mode 100644 index 0000000..935d47f --- /dev/null +++ b/src/test/java/previewcode/backend/test/helpers/TestStore.java @@ -0,0 +1,30 @@ +package previewcode.backend.test.helpers; + +import com.google.inject.servlet.ServletModule; +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.util.Optional; + +public class TestStore extends AnnotatedClassInstantiator { + + protected T getFromStore(ExtensionContext context) { + T t = (T) getStore(context).get(getStoreKey(context), Object.class); + if (t != null) { + return t; + } else { + return context.getParent().map(this::getFromStore).orElse(null); + } + } + + protected void putObjectToStore(ExtensionContext context, T obj) { + getStore(context).put(getStoreKey(context), obj); + } + + private String getStoreKey(ExtensionContext context) { + return context.getUniqueId(); + } + + private ExtensionContext.Store getStore(ExtensionContext context) { + return context.getStore(ExtensionContext.Namespace.create(getClass(), context)); + } +} From 87d24496f27e732f77e653fb3c0016f362fe4151 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Thu, 6 Jul 2017 14:11:25 +0200 Subject: [PATCH 05/22] Change configuration from ENV vars to a yaml-file (#55) * Change configuration from ENV vars to a yaml-file * Get the config file path as command line argument --- .idea/runConfigurations/run.xml | 10 +- config.yaml | 21 ++++ src/main/java/previewcode/backend/Config.java | 99 +++++++++++++++++++ src/main/java/previewcode/backend/Main.java | 26 +++-- .../java/previewcode/backend/MainModule.java | 75 +++++++------- 5 files changed, 171 insertions(+), 60 deletions(-) create mode 100644 config.yaml create mode 100644 src/main/java/previewcode/backend/Config.java diff --git a/.idea/runConfigurations/run.xml b/.idea/runConfigurations/run.xml index 099034a..0a4f66a 100644 --- a/.idea/runConfigurations/run.xml +++ b/.idea/runConfigurations/run.xml @@ -8,7 +8,7 @@ CheckedFunction1 toUnit(Consumer f) { }; } + @SuppressWarnings("unchecked") + public static R sneakyThrow(Throwable t) throws T { + throw (T) t; + } + public static Interpreter interpret() { return new Interpreter(); } diff --git a/src/test/java/previewcode/backend/api/v2/EndPointTest.java b/src/test/java/previewcode/backend/api/v2/EndPointTest.java index 503fd2c..fc35773 100644 --- a/src/test/java/previewcode/backend/api/v2/EndPointTest.java +++ b/src/test/java/previewcode/backend/api/v2/EndPointTest.java @@ -1,11 +1,14 @@ package previewcode.backend.api.v2; import com.google.inject.name.Names; +import io.atlassian.fugue.Try; import io.atlassian.fugue.Unit; import io.vavr.collection.List; import org.junit.jupiter.api.Test; +import org.kohsuke.github.GHMyself; import previewcode.backend.APIModule; import previewcode.backend.DTO.*; +import previewcode.backend.services.IGithubService; import previewcode.backend.services.actiondsl.Interpreter; import previewcode.backend.test.helpers.ApiEndPointTest; import previewcode.backend.database.PullRequestGroup; @@ -15,6 +18,7 @@ import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.Response; +import java.io.IOException; import java.util.ArrayList; import static org.assertj.core.api.Assertions.*; @@ -68,23 +72,34 @@ public void getApprovalsApiIsReachable(WebTarget target) { assertThat(response.getLength()).isZero(); assertThat(response.getStatus()).isEqualTo(200); } +} +class TestModule extends APIModule implements IDatabaseService, IGithubService { - @Test - public void getHunkApprovalsApiIsReachable(WebTarget target) { - Response response = target - .path("/v2/preview-code/backend/pulls/42/getHunkApprovals") - .request("application/json") - .get(); + public TestModule() {} + + @SuppressWarnings("unchecked") + @Override + public void configureServlets() { + super.configureServlets(); + // The DatabaseService always returns a no-op action + this.bind(IDatabaseService.class).toInstance(this); + this.bind(IGithubService.class).toInstance(this); +// The interpreter always evaluates any action to Unit + this.bind(Interpreter.class).to(TestInterpreter.class); + this.bind(Interpreter.class).annotatedWith(Names.named("database-interp")).to(TestInterpreter.class); - assertThat(response.getLength()).isZero(); - assertThat(response.getStatus()).isEqualTo(200); } -} -class TestModule extends APIModule implements IDatabaseService { + public static class TestInterpreter extends Interpreter { + + @Override + protected Try run(Action action) { + return Try.successful((A) unit); + } + } + - public TestModule() {} @Override public Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, List body) { @@ -111,22 +126,25 @@ public Action getApproval(PullRequestIdentifier pull) { return new NoOp<>(); } + + @Override - public Action> getHunkApprovals(PullRequestIdentifier pull) { - return new NoOp<>(); + public GHMyself getLoggedInUser() throws IOException { + return null; } + @Override + public PrNumber createPullRequest(String owner, String name, PRbody body) { + return null; + } - @SuppressWarnings("unchecked") @Override - public void configureServlets() { - super.configureServlets(); - // The DatabaseService always returns a no-op action - this.bind(IDatabaseService.class).toInstance(this); + public GitHubPullRequest fetchPullRequest(PullRequestIdentifier identifier) throws IOException { + return null; + } + + @Override + public void setPRStatus(GitHubPullRequest pullRequest, ApproveStatus status) throws IOException { -// The interpreter always evaluates any action to Unit - this.bind(Interpreter.class).annotatedWith(Names.named("database-interp")).toInstance( - interpret().on(NoOp.class).apply(x -> unit) - ); } } diff --git a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java index 486fb77..23e5ef5 100644 --- a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java +++ b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java @@ -262,79 +262,4 @@ void getApproval_fetches_hunk_approvals(){ assertThat(stepper.next()).containsOnly(new FetchHunkApprovals(id)); assertThat(stepper.next()).isEmpty(); } - - @Test - void getHunkApproval_fetches_pull_pullRequest() { - Action dbAction = service.getHunkApprovals(pullIdentifier); - - List> peek = interpret().stepwiseEval(dbAction).peek(); - assertThat(peek).containsOnly(fetchPull(pullIdentifier)); - } - - @Test - void getHunkApproval_fetches_pull_groups(){ - Action dbAction = service.getHunkApprovals(pullIdentifier); - - Interpreter.Stepper stepper = interpret() - .on(FetchPull.class).returnA(pullRequestID) - .stepwiseEval(dbAction); - List> next = stepper.next(); - assertThat(next).containsOnly(fetchGroups(pullRequestID)); - } - - @Test - void getHunkApproval_fetches_hunks(){ - Action dbAction = service.getHunkApprovals(pullIdentifier); - - List oneGroup = List.of( - new PullRequestGroup(new GroupID(42L), "Group A", "Description A", defaultGroup) - ); - - Interpreter.Stepper stepper = interpret() - .on(FetchPull.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(oneGroup) - .stepwiseEval(dbAction); - stepper.next(); - List> next = stepper.next(); - assertThat(next).containsOnly(fetchHunks(oneGroup.head().id)); - } - - @Test - void getHunkApproval_fetches_hunk_approvals(){ - Action dbAction = service.getHunkApprovals(pullIdentifier); - - HunkChecksum checksum = new HunkChecksum("abcd"); - HunkID id = new HunkID(1L); - List oneHunk = List.of(new Hunk(id, new GroupID(2L), checksum)); - - Interpreter.Stepper stepper = interpret() - .on(FetchPull.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(groups) - .on(FetchHunksForGroup.class).returnA(oneHunk) - .stepwiseEval(dbAction); - stepper.next(); - stepper.next(); - - List> next = stepper.next(); - assertThat(next.length()).isEqualTo(2); - assertThat(next).containsOnly(fetchApprovals(id)); - } - - @Test - void getHunkApproval_no_action_after_fetching_hunkapprovals(){ - Action dbAction = service.getHunkApprovals(pullIdentifier); - - Interpreter.Stepper stepper = interpret() - .on(FetchPull.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(groups) - .on(FetchHunksForGroup.class).returnA(hunkIDs.map(id -> new Hunk(new HunkID(-1L), new GroupID(2L), id))) - .on(FetchHunkApprovals.class).returnA(List.empty()) - .stepwiseEval(dbAction); - stepper.next(); - stepper.next(); - stepper.next(); - - List> next = stepper.next(); - assertThat(next).isEmpty(); - } } diff --git a/src/test/java/previewcode/backend/services/GitHubServiceTest.java b/src/test/java/previewcode/backend/services/GitHubServiceTest.java index 36b43e2..865d3ee 100644 --- a/src/test/java/previewcode/backend/services/GitHubServiceTest.java +++ b/src/test/java/previewcode/backend/services/GitHubServiceTest.java @@ -19,7 +19,7 @@ public class GitHubServiceTest { - GithubService.V2 ghService = new GithubService.V2(); + IGithubService.V2 ghService = new IGithubService.V2(); Action authAction = ghService.authenticate(); From e383e733adb7c0fdebe0b0dadbf518266425ee6c Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Fri, 7 Jul 2017 16:44:02 +0200 Subject: [PATCH 11/22] Allow hunks to be re-assigned to another group (#57) --- .../backend/services/DatabaseService.java | 46 +++++++++---------- .../interpreters/DatabaseInterpreter.java | 3 ++ .../DatabaseInterpreter_HunksTest.java | 15 +++--- .../backend/services/DatabaseServiceTest.java | 28 ++++++++--- 4 files changed, 54 insertions(+), 38 deletions(-) diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 6474a11..f45a229 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -17,10 +17,14 @@ public class DatabaseService implements IDatabaseService { @Override - public Action updateOrdering(PullRequestIdentifier pull, List groups) { - return insertPullIfNotExists(pull) - .then(this::clearExistingGroups) - .then(dbPullId -> traverse(groups, createGroup(dbPullId, false))).toUnit(); + public Action updateOrdering(PullRequestIdentifier pull, List newGroups) { + return insertPullIfNotExists(pull).then(pullID -> + fetchGroups(pullID).then(existingGroups -> + traverse(newGroups, createGroup(pullID, false)).then( + traverse(existingGroups, g -> delete(g.id)) + ) + ) + ).toUnit(); } @Override @@ -61,12 +65,6 @@ public Function> createGroup(PullRequestID dbPullId, ).toUnit(); } - public Action clearExistingGroups(PullRequestID dbPullId) { - return fetchGroups(dbPullId) - .then(traverse(group -> delete(group.id))) - .map(unit -> dbPullId); - } - private static Action getGroupApproval(GroupID groupID) { return fetchHunks(groupID).then( hunks -> traverse(hunks, (Hunk h) -> h.fetchApprovals.map(approvals -> { @@ -78,20 +76,20 @@ private static Action getGroupApproval(GroupID groupID) { map.put(h.checksum.checksum, hApprovals); return map; })) - .map(DatabaseService::combineMaps) - .map(approvals -> { - Map approvalMap = new HashMap<>(); - java.util.List hunkList = new ArrayList(); - approvals.forEach((hunk, approval) -> { - approvalMap.put(hunk, approval.approved); - hunkList.add(approval); - }); - - return new ApprovedGroup( - isGroupApproved(hunks.length(), approvalMap), - hunkList, - groupID); - }) + .map(DatabaseService::combineMaps) + .map(approvals -> { + Map approvalMap = new HashMap<>(); + java.util.List hunkList = new ArrayList<>(); + approvals.forEach((hunk, approval) -> { + approvalMap.put(hunk, approval.approved); + hunkList.add(approval); + }); + + return new ApprovedGroup( + isGroupApproved(hunks.length(), approvalMap), + hunkList, + groupID); + }) ); } diff --git a/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java b/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java index 9bba33d..f34f085 100644 --- a/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java +++ b/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java @@ -60,6 +60,9 @@ protected void assignHunk(AssignHunkToGroup action) { .from(GROUPS) .where(GROUPS.ID.eq(action.groupID.id)) ) + .onConflict(HUNK.CHECKSUM, HUNK.PULL_REQUEST_ID) + .doUpdate() + .set(HUNK.GROUP_ID, action.groupID.id) .execute(); if (result == 0) { diff --git a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java index 62e84b9..0ee18c8 100644 --- a/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java +++ b/src/test/java/previewcode/backend/database/DatabaseInterpreter_HunksTest.java @@ -47,11 +47,11 @@ public void assignHunk_groupMustExist(){ } @Test - public void assignHunk_cannotAssignTwice_toSameGroup() { + public void assignHunk_cannotAssignTwice_toSameGroup(DSLContext db) { AssignHunkToGroup assign = assignToGroup(group_A_id, hunkChecksum); + eval(assign.then(assign)); - assertThatExceptionOfType(DataAccessException.class) - .isThrownBy(() -> eval(assign.then(assign))); + assertThat(db.selectCount().from(HUNK).fetchOneInto(Integer.class)).isOne(); } @Test @@ -64,12 +64,11 @@ public void assignHunk_insertsIntoHunkTable(DSLContext db){ } @Test - public void assignHunk_cannotAssignTwice_toDifferentGroup(DSLContext db){ + void assignHunk_whenAlreadyAssigned_hunkSwitchesGroup(DSLContext db) { Action assignDouble = assignToGroup(group_A_id, hunkChecksum).then(assignToGroup(group_B_id, hunkChecksum)); - assertThatExceptionOfType(DataAccessException.class) - .isThrownBy(() -> - eval(assignDouble) - ); + eval(assignDouble); + assertThat(db.selectCount().from(HUNK).fetchOneInto(Integer.class)).isOne(); + assertThat(db.selectFrom(HUNK).fetchOne(HUNK.GROUP_ID)).isEqualTo(group_B_id.id); } @Test diff --git a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java index 23e5ef5..7d631b5 100644 --- a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java +++ b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java @@ -53,7 +53,7 @@ public class DatabaseServiceTest { private ApproveRequest approveStatus = new ApproveRequest("checksum", ApproveStatus.DISAPPROVED, "txsmith"); @Test - public void insertsPullIfNotExists(){ + public void updateOrdering_insertsPullIfNotExists() { Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); Consumer assertions = action -> { @@ -70,7 +70,23 @@ public void insertsPullIfNotExists(){ } @Test - public void removesExistingGroups(){ + void updateOrdering_firstInsertsNewGroups() { + Action dbAction = service.updateOrdering(pullIdentifier, groupsWithoutHunks); + + class DoneException extends RuntimeException {} + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(NewGroup.class).apply(action -> { throw new DoneException(); }); + + assertThatExceptionOfType(DoneException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(dbAction)); + } + + @Test + public void updateOrdering_removesExistingGroups() { Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); Collection removedGroups = Lists.newArrayList(); @@ -91,7 +107,7 @@ public void removesExistingGroups(){ } @Test - public void doesNotRemoveGroups(){ + public void updateOrdering_doesNotRemoveGroups_whenThereAreNone() { Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); Interpreter interpreter = @@ -103,7 +119,7 @@ public void doesNotRemoveGroups(){ } @Test - public void insertsNewGroupsWithoutHunks(){ + public void updateOrdering_insertsNewGroupsWithoutHunks() { Action dbAction = service.updateOrdering(pullIdentifier, groupsWithoutHunks); Collection groupsAdded = Lists.newArrayList(); @@ -127,7 +143,7 @@ public void insertsNewGroupsWithoutHunks(){ } @Test - public void insertsNewGroupsWithHunks(){ + public void updateOrdering_insertsNewGroupsWithHunks() { Action dbAction = service.updateOrdering(pullIdentifier, groupsWithHunks); Collection hunksAdded = Lists.newArrayList(); @@ -137,7 +153,7 @@ public void insertsNewGroupsWithHunks(){ .on(InsertPullIfNotExists.class).returnA(pullRequestID) .on(FetchGroupsForPull.class).returnA(List.empty()) .on(NewGroup.class).apply(action -> - groups.find(g -> g.title.equals(action.title)).get().id) + groups.find(g -> g.title.equals(action.title)).get().id) .on(AssignHunkToGroup.class).apply(toUnit(action -> { assertThat(groups.find(g -> g.id.equals(action.groupID))).isNotEmpty(); Option hunkID = hunkIDs.find(id -> id.checksum.equals(action.hunkChecksum)); From 6911cd57eea7634f1f1a19bc565c8accd4fbbac9 Mon Sep 17 00:00:00 2001 From: Eva Anker Date: Mon, 6 Nov 2017 12:38:55 +0000 Subject: [PATCH 12/22] Added getOrdering method (#60) --- .../java/previewcode/backend/DTO/Diff.java | 2 +- .../previewcode/backend/DTO/Ordering.java | 35 +++++++ .../backend/DTO/OrderingGroup.java | 36 ++++++- .../backend/DTO/OrderingGroupWithID.java | 2 +- .../backend/DTO/TitleDescription.java | 18 ++++ .../backend/api/v2/OrderingAPI.java | 14 +++ .../backend/services/DatabaseService.java | 44 +++++++-- .../backend/services/DiffParser.java | 5 +- .../backend/services/IDatabaseService.java | 3 +- .../backend/api/v2/EndPointTest.java | 5 + .../backend/services/DatabaseServiceTest.java | 97 +++++++++++++++---- 11 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 src/main/java/previewcode/backend/DTO/Ordering.java diff --git a/src/main/java/previewcode/backend/DTO/Diff.java b/src/main/java/previewcode/backend/DTO/Diff.java index 8925c41..95717ae 100644 --- a/src/main/java/previewcode/backend/DTO/Diff.java +++ b/src/main/java/previewcode/backend/DTO/Diff.java @@ -1,8 +1,8 @@ package previewcode.backend.DTO; +import io.vavr.collection.List; import previewcode.backend.services.DiffParser; -import java.util.List; /** * Diff from GitHub, calculates hunkChecksums diff --git a/src/main/java/previewcode/backend/DTO/Ordering.java b/src/main/java/previewcode/backend/DTO/Ordering.java new file mode 100644 index 0000000..e383771 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/Ordering.java @@ -0,0 +1,35 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.vavr.collection.List; + +public class Ordering { + + @JsonProperty("defaultGroup") + public OrderingGroup defaultGroup; + @JsonProperty("ordering") + public List ordering; + + public Ordering(OrderingGroup defaultGroup, List ordering) { + this.defaultGroup = defaultGroup; + this.ordering = ordering; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Ordering ordering1 = (Ordering) o; + + if (!defaultGroup.equals(ordering1.defaultGroup)) return false; + return ordering.equals(ordering1.ordering); + } + + @Override + public int hashCode() { + int result = defaultGroup.hashCode(); + result = 31 * result + ordering.hashCode(); + return result; + } +} diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroup.java b/src/main/java/previewcode/backend/DTO/OrderingGroup.java index 0cf9bfa..d05f93f 100644 --- a/src/main/java/previewcode/backend/DTO/OrderingGroup.java +++ b/src/main/java/previewcode/backend/DTO/OrderingGroup.java @@ -3,8 +3,8 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import io.vavr.collection.List; -import java.util.List; /** * The ordering of the pull request * @@ -24,6 +24,8 @@ public class OrderingGroup { @JsonProperty("info") public final TitleDescription info; + public final Boolean defaultGroup; + @JsonCreator public OrderingGroup( @@ -35,6 +37,7 @@ public OrderingGroup( title = ""; } this.info = new TitleDescription(title, info.description); + this.defaultGroup = false; } public OrderingGroup(String title, String description, List hunks) { @@ -43,5 +46,36 @@ public OrderingGroup(String title, String description, List hunks) title = ""; } this.info = new TitleDescription(title, description); + this.defaultGroup = false; + } + + public OrderingGroup(String title, String description, List hunks, boolean defaultGroup) { + this.hunkChecksums = hunks; + if(title == null){ + title = ""; + } + this.info = new TitleDescription(title, description); + this.defaultGroup = defaultGroup; + + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + OrderingGroup that = (OrderingGroup) o; + + if (!hunkChecksums.equals(that.hunkChecksums)) return false; + if (!info.equals(that.info)) return false; + return defaultGroup.equals(that.defaultGroup); + } + + @Override + public int hashCode() { + int result = hunkChecksums.hashCode(); + result = 31 * result + info.hashCode(); + result = 31 * result + defaultGroup.hashCode(); + return result; } } diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java index ba98db6..21d92a6 100644 --- a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java +++ b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import io.vavr.collection.List; import previewcode.backend.database.PullRequestGroup; -import java.util.List; @JsonIgnoreProperties(ignoreUnknown=true) public class OrderingGroupWithID extends OrderingGroup { diff --git a/src/main/java/previewcode/backend/DTO/TitleDescription.java b/src/main/java/previewcode/backend/DTO/TitleDescription.java index fa8b394..ef783af 100644 --- a/src/main/java/previewcode/backend/DTO/TitleDescription.java +++ b/src/main/java/previewcode/backend/DTO/TitleDescription.java @@ -30,4 +30,22 @@ public TitleDescription( this.title = title; this.description = description; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TitleDescription that = (TitleDescription) o; + + if (!title.equals(that.title)) return false; + return description.equals(that.description); + } + + @Override + public int hashCode() { + int result = title.hashCode(); + result = 31 * result + description.hashCode(); + return result; + } } diff --git a/src/main/java/previewcode/backend/api/v2/OrderingAPI.java b/src/main/java/previewcode/backend/api/v2/OrderingAPI.java index 1449a5c..23b149a 100644 --- a/src/main/java/previewcode/backend/api/v2/OrderingAPI.java +++ b/src/main/java/previewcode/backend/api/v2/OrderingAPI.java @@ -3,6 +3,7 @@ import com.google.inject.Inject; import io.atlassian.fugue.Unit; import io.vavr.collection.List; +import previewcode.backend.DTO.Ordering; import previewcode.backend.DTO.OrderingGroup; import previewcode.backend.DTO.PullRequestIdentifier; import previewcode.backend.services.IDatabaseService; @@ -11,6 +12,7 @@ import static previewcode.backend.services.actiondsl.ActionDSL.*; import javax.inject.Named; +import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; @@ -41,4 +43,16 @@ public Response updateOrdering( return interpreter.evaluateToResponse(action); } + + @GET + public Response getOrdering( + @PathParam("owner") String owner, + @PathParam("name") String name, + @PathParam("number") Integer number + ){ + + PullRequestIdentifier pull = new PullRequestIdentifier(owner, name, number); + Action action = databaseService.getOrdering(pull); + return interpreter.evaluateToResponse(action); + } } diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index f45a229..6a7fd0c 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -5,8 +5,10 @@ import io.vavr.collection.List; import previewcode.backend.DTO.*; import previewcode.backend.database.*; +import previewcode.backend.database.model.tables.PullRequest; import previewcode.backend.services.actions.DatabaseActions; +import java.security.acl.Group; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; @@ -20,13 +22,39 @@ public class DatabaseService implements IDatabaseService { public Action updateOrdering(PullRequestIdentifier pull, List newGroups) { return insertPullIfNotExists(pull).then(pullID -> fetchGroups(pullID).then(existingGroups -> - traverse(newGroups, createGroup(pullID, false)).then( - traverse(existingGroups, g -> delete(g.id)) - ) + traverse(newGroups, createGroup(pullID, false)).then( + traverse(existingGroups, g -> delete(g.id)) + ) ) ).toUnit(); } + @Override + public Action getOrdering(PullRequestIdentifier pull) { + + return fetchPullRequestGroups(pull).then(this::fetchPullOrdering); + } + + public Action> fetchGroupHunks(PullRequestGroup group) { + return fetchHunks(group.id).map(hunks -> hunks.map(hunk -> hunk.checksum)); + } + + public Action fetchGroupOrdering(PullRequestGroup group) { + return fetchGroupHunks(group).map(hunkChecksums -> + new OrderingGroup(group.title, group.description, hunkChecksums, group.defaultGroup)); + } + + public Ordering createOrdering(List orderingGroups) { + return new Ordering( + orderingGroups.find(group -> group.defaultGroup).get(), + orderingGroups.filter(group -> !group.defaultGroup)); + } + + public Action fetchPullOrdering(List pullRequestGroups) { + return traverse(pullRequestGroups, this::fetchGroupOrdering).map(this::createOrdering); + } + + @Override public Action insertDefaultGroup(PullRequestIdentifier pull, OrderingGroup group) { return insertPullIfNotExists(pull) @@ -46,7 +74,7 @@ public Action> fetchPullRequestGroups(PullRequestIdentifi @Override public Action getApproval(PullRequestIdentifier pull) { - Function> toMap = approvedGroup -> { + Function> toMap = approvedGroup -> { Map map = new HashMap<>(); map.put(approvedGroup.groupID.id, approvedGroup); return map; @@ -61,7 +89,7 @@ public Action getApproval(PullRequestIdentifier pull) { public Function> createGroup(PullRequestID dbPullId, Boolean defaultGroup) { return group -> newGroup(dbPullId, group.info.title, group.info.description, defaultGroup).then( - groupID -> traverse(List.ofAll(group.hunkChecksums), hunkId -> assignToGroup(groupID, hunkId.checksum)) + groupID -> traverse(List.ofAll(group.hunkChecksums), hunkId -> assignToGroup(groupID, hunkId.checksum)) ).toUnit(); } @@ -94,8 +122,7 @@ private static Action getGroupApproval(GroupID groupID) { } - - private static Map combineMaps(List> maps) { + private static Map combineMaps(List> maps) { return maps.fold(new HashMap<>(), (a, b) -> { a.putAll(b); return a; @@ -129,5 +156,6 @@ private static ApproveStatus isHunkApproved(List statuses) { return ApproveStatus.NONE; } } - } + + diff --git a/src/main/java/previewcode/backend/services/DiffParser.java b/src/main/java/previewcode/backend/services/DiffParser.java index 96a4d97..67f855e 100644 --- a/src/main/java/previewcode/backend/services/DiffParser.java +++ b/src/main/java/previewcode/backend/services/DiffParser.java @@ -1,13 +1,14 @@ package previewcode.backend.services; +import io.vavr.collection.List; import jregex.Matcher; import jregex.Pattern; import org.apache.commons.codec.binary.Base64; import previewcode.backend.DTO.HunkChecksum; import java.util.ArrayList; -import java.util.List; + /** * Parser for GitHub diffs @@ -62,7 +63,7 @@ public List parseDiff(String diff) { } } } - return hunkChecksums; + return List.ofAll(hunkChecksums); } /** diff --git a/src/main/java/previewcode/backend/services/IDatabaseService.java b/src/main/java/previewcode/backend/services/IDatabaseService.java index 4be80b0..414b346 100644 --- a/src/main/java/previewcode/backend/services/IDatabaseService.java +++ b/src/main/java/previewcode/backend/services/IDatabaseService.java @@ -2,7 +2,6 @@ import io.atlassian.fugue.Unit; import io.vavr.collection.List; -import io.vavr.collection.Seq; import previewcode.backend.DTO.*; import previewcode.backend.database.PullRequestGroup; @@ -11,6 +10,8 @@ public interface IDatabaseService { Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, List body); + Action getOrdering(PullRequestIdentifier pullRequestIdentifier); + Action insertDefaultGroup(PullRequestIdentifier pullRequestIdentifier, OrderingGroup body); Action setApproval(PullRequestIdentifier pullRequestIdentifier, ApproveRequest approval); diff --git a/src/test/java/previewcode/backend/api/v2/EndPointTest.java b/src/test/java/previewcode/backend/api/v2/EndPointTest.java index fc35773..267de17 100644 --- a/src/test/java/previewcode/backend/api/v2/EndPointTest.java +++ b/src/test/java/previewcode/backend/api/v2/EndPointTest.java @@ -126,6 +126,11 @@ public Action getApproval(PullRequestIdentifier pull) { return new NoOp<>(); } + @Override + public Action getOrdering(PullRequestIdentifier pull) { + return new NoOp<>(); + } + @Override diff --git a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java index 7d631b5..5edf8b8 100644 --- a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java +++ b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java @@ -4,10 +4,12 @@ import io.atlassian.fugue.Unit; import io.vavr.collection.List; import io.vavr.control.Option; +import org.junit.Assert; import org.junit.jupiter.api.Test; import previewcode.backend.DTO.*; import previewcode.backend.database.*; import previewcode.backend.DTO.HunkChecksum; +import previewcode.backend.database.model.tables.PullRequest; import previewcode.backend.services.actiondsl.Interpreter; import java.util.Collection; @@ -34,20 +36,25 @@ public class DatabaseServiceTest { private PullRequestIdentifier pullIdentifier = new PullRequestIdentifier(owner, name, number); private Boolean defaultGroup = false; - private List groups = List.of( - new PullRequestGroup(new GroupID(42L), "Group A", "Description A", defaultGroup), - new PullRequestGroup(new GroupID(24L), "Group B", "Description B", defaultGroup) - ); + private PullRequestGroup groupDefault = new PullRequestGroup(new GroupID(42L), "Group A", "Description A", true); + private PullRequestGroup groupOther = new PullRequestGroup(new GroupID(24L), "Group B", "Description B", false); + private PullRequestGroup groupOtherOne = new PullRequestGroup(new GroupID(21L), "Group C", "Description C", false); + private List groups = List.of(groupDefault,groupOther,groupOtherOne); + + + HunkChecksum checksum = new HunkChecksum("abcd"); + HunkID id = new HunkID(1L); + List oneHunk = List.of(new Hunk(id, new GroupID(2L), checksum)); private List groupsWithoutHunks= groups.map(group -> - new OrderingGroupWithID(group, Lists.newLinkedList()) + new OrderingGroupWithID(group, List.empty()) ); private List hunkIDs = List.of( new HunkChecksum("abcd"), new HunkChecksum("efgh"), new HunkChecksum("ijkl")); private List groupsWithHunks = groups.map(group -> - new OrderingGroupWithID(group, hunkIDs.map(id -> id).toJavaList()) + new OrderingGroupWithID(group, hunkIDs.map(id -> id)) ); private ApproveRequest approveStatus = new ApproveRequest("checksum", ApproveStatus.DISAPPROVED, "txsmith"); @@ -153,7 +160,7 @@ public void updateOrdering_insertsNewGroupsWithHunks() { .on(InsertPullIfNotExists.class).returnA(pullRequestID) .on(FetchGroupsForPull.class).returnA(List.empty()) .on(NewGroup.class).apply(action -> - groups.find(g -> g.title.equals(action.title)).get().id) + groups.find(g -> g.title.equals(action.title)).get().id) .on(AssignHunkToGroup.class).apply(toUnit(action -> { assertThat(groups.find(g -> g.id.equals(action.groupID))).isNotEmpty(); Option hunkID = hunkIDs.find(id -> id.checksum.equals(action.hunkChecksum)); @@ -170,20 +177,20 @@ public void updateOrdering_insertsNewGroupsWithHunks() { @Test public void insertsDefaultGroup() throws Exception { PullRequestGroup group = new PullRequestGroup(new GroupID(42L), "Group A", "Description A", true); - OrderingGroup defaultGroup = new OrderingGroupWithID(group, hunkIDs.map(id -> id).toJavaList()); + OrderingGroup defaultGroup = new OrderingGroupWithID(group, hunkIDs.map(id -> id)); Action dbAction = service.insertDefaultGroup(pullIdentifier, defaultGroup); Collection groupsAdded = Lists.newArrayList(); Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(NewGroup.class).apply(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(NewGroup.class).apply(action -> { assertThat(action.defaultGroup).isEqualTo(true); groupsAdded.add(group); return group.id; }) - .on(AssignHunkToGroup.class).apply(toUnit(action -> { + .on(AssignHunkToGroup.class).apply(toUnit(action -> { assertThat(List.of(group).find(g -> g.id.equals(action.groupID))).isNotEmpty(); Option hunkID = hunkIDs.find(id -> id.checksum.equals(action.hunkChecksum)); assertThat(hunkID).isNotEmpty(); @@ -201,8 +208,8 @@ public void insertApproval() { Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(ApproveHunk.class).stop(approveHunk -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(ApproveHunk.class).stop(approveHunk -> { assertThat(approveHunk.status) .isEqualTo(ApproveStatus.DISAPPROVED); assertThat(approveHunk.githubUser) @@ -260,10 +267,6 @@ void getApproval_fetches_hunks(){ void getApproval_fetches_hunk_approvals(){ Action dbAction = service.getApproval(pullIdentifier); - HunkChecksum checksum = new HunkChecksum("abcd"); - HunkID id = new HunkID(1L); - List oneHunk = List.of(new Hunk(id, new GroupID(2L), checksum)); - List hunkApprovals = List.of(new HunkApproval(ApproveStatus.APPROVED, "txsmith")); Interpreter.Stepper stepper = interpret() @@ -278,4 +281,64 @@ void getApproval_fetches_hunk_approvals(){ assertThat(stepper.next()).containsOnly(new FetchHunkApprovals(id)); assertThat(stepper.next()).isEmpty(); } + + + @Test + void getOrdering_fetches_pull_pullRequest() { + Action dbAction = service.getOrdering(pullIdentifier); + + Interpreter.Stepper stepper = interpret().stepwiseEval(dbAction); + List> peek = stepper.peek(); + assertThat(peek).containsOnly(fetchPull(pullIdentifier)); + } + + + @Test + void getOrdering_fetches_pull_groups(){ + Action dbAction = service.getOrdering(pullIdentifier); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .stepwiseEval(dbAction); + List> next = stepper.next(); + assertThat(next).containsOnly(fetchGroups(pullRequestID)); + } + + + @Test + void getOrdering_fetches_hunks(){ + Action dbAction = service.getOrdering(pullIdentifier); + + List oneGroup = List.of( + new PullRequestGroup(new GroupID(42L), "Group A", "Description A", defaultGroup) + ); + + Interpreter.Stepper stepper = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(oneGroup) + .stepwiseEval(dbAction); + stepper.next(); + List> next = stepper.next(); + assertThat(next).containsOnly(fetchHunks(oneGroup.head().id)); + } + + @Test + void getOrdering_fetches_ordering(){ + Action dbAction = service.getOrdering(pullIdentifier); + + OrderingGroup defaultOrderingGroup = new OrderingGroup("Group A", "Description A", List.of(checksum), true); + OrderingGroup firstGroup = new OrderingGroup("Group B", "Description B", List.of(checksum), false); + OrderingGroup secondGroup = new OrderingGroup("Group C", "Description C", List.of(checksum), false); + + Ordering ordering = new Ordering(defaultOrderingGroup, List.of(firstGroup, secondGroup)); + + Ordering result = interpret() + .on(FetchPull.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(FetchHunksForGroup.class).returnA(oneHunk) + .unsafeEvaluate(dbAction); + + Assert.assertEquals(result, ordering); + } + } From 18dc1aead16bd391647ca16054b96da248c33851 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Mon, 6 Nov 2017 13:46:50 +0100 Subject: [PATCH 13/22] Fix composition of Interpreters (#58) --- .../previewcode/backend/services/actiondsl/Interpreter.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java index 834fff9..6c475e0 100644 --- a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java +++ b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java @@ -88,7 +88,9 @@ public Interpreter() { */ public Interpreter(Interpreter ... interpreters) { this(); - List.of(interpreters).forEach(interpreter -> handlers.putAll(interpreter.handlers)); + List.of(interpreters).forEach(interpreter -> interpreter.handlers.forEach(((actionClass, __) -> { + handlers.put(actionClass, (Function, Object>) interpreter::unsafeEvaluate); + }))); } /** From 40306e13d5f5368bde41702b2899f54884c3add4 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Mon, 6 Nov 2017 14:24:57 +0100 Subject: [PATCH 14/22] Switch to jUnit 5 release (#61) --- pom.xml | 39 +++++++++---------- .../test/helpers/DatabaseTestExtension.java | 6 +-- .../test/helpers/GuiceResteasyExtension.java | 12 +++--- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/pom.xml b/pom.xml index c592c00..8b56c84 100644 --- a/pom.xml +++ b/pom.xml @@ -19,11 +19,10 @@ 42.0.0 3.9.2 - 5.0.0-M4 - ${junit.version}.0-M4 - 1.0.0-M4 - 4.12 + 5.0.0 + ${junit.version}.0 + 1.0.0 jdbc:postgresql://localhost:5432/preview_code preview_code @@ -244,11 +243,11 @@ org.junit.jupiter - junit-jupiter-api + junit-jupiter-engine ${junit.jupiter.version} test - + junit junit @@ -257,18 +256,25 @@ - org.assertj - assertj-core - 3.7.0 + org.junit.platform + junit-platform-runner + ${junit.platform.version} test - org.junit.platform - junit-platform-launcher - 1.0.0-M4 + org.junit.vintage + junit-vintage-engine + ${junit.vintage.version} + test + + org.assertj + assertj-core + 3.7.0 + test + com.google.guava @@ -284,13 +290,6 @@ test - - - - - - - @@ -412,7 +411,7 @@ maven-surefire-plugin - 2.19.1 + 2.19 **/Test*.java diff --git a/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java b/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java index 9a48127..f3bdf9f 100644 --- a/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java +++ b/src/test/java/previewcode/backend/test/helpers/DatabaseTestExtension.java @@ -17,13 +17,13 @@ public class DatabaseTestExtension extends TestStore implements Para private static final Logger logger = LoggerFactory.getLogger(DatabaseTestExtension.class); @Override - public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { Class parameterType = parameterContext.getParameter().getType(); return DSLContext.class.isAssignableFrom(parameterType); } @Override - public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { Settings settings = new Settings() .withExecuteLogging(true); @@ -39,7 +39,7 @@ public Object resolve(ParameterContext parameterContext, ExtensionContext extens } @Override - public void afterEach(TestExtensionContext context) throws Exception { + public void afterEach(ExtensionContext context) throws Exception { DSLContext db = getFromStore(context); if (db != null) { logger.debug("Commence database cleanup."); diff --git a/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java b/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java index 0572967..60c5969 100644 --- a/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java +++ b/src/test/java/previewcode/backend/test/helpers/GuiceResteasyExtension.java @@ -24,7 +24,7 @@ public class GuiceResteasyExtension extends TestStore(new ResteasyClientBuilder().build(), server)); } - private ServletModule getServletModule(ContainerExtensionContext context) { + private ServletModule getServletModule(ExtensionContext context) { return Try.of(() -> context.getTestClass().get()) .flatMap(cls -> instantiateAnnotationValue(ApiEndPointTest.class, cls)) .get(); @@ -57,7 +57,7 @@ private ServletModule getServletModule(ContainerExtensionContext context) { @Override - public void afterAll(ContainerExtensionContext context) throws Exception { + public void afterAll(ExtensionContext context) throws Exception { Tuple2 t = getFromStore(context); ResteasyClient client = t._1; Server server = t._2; @@ -67,18 +67,18 @@ public void afterAll(ContainerExtensionContext context) throws Exception { } } + @Override - public boolean supports(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { return WebTarget.class.isAssignableFrom(parameterContext.getParameter().getType()); } @Override - public Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { Tuple2 t = getFromStore(extensionContext.getParent().get()); ResteasyClient client = t._1; Server server = t._2; return client.target(server.getURI()); } - } From 37067333bb80b79bf841dcbc3c3a5af8e6105b72 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Tue, 7 Nov 2017 10:55:03 +0100 Subject: [PATCH 15/22] Start implementing handling new commits --- .../backend/DTO/OrderingGroup.java | 16 ++++++++++ .../backend/api/v1/WebhookAPI.java | 13 ++++++-- .../backend/services/DatabaseService.java | 30 ++++++++++++++----- .../backend/services/IDatabaseService.java | 4 ++- .../services/actiondsl/Interpreter.java | 2 +- .../backend/api/v2/EndPointTest.java | 2 +- .../backend/services/DatabaseServiceTest.java | 3 +- 7 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroup.java b/src/main/java/previewcode/backend/DTO/OrderingGroup.java index d05f93f..ce30b2b 100644 --- a/src/main/java/previewcode/backend/DTO/OrderingGroup.java +++ b/src/main/java/previewcode/backend/DTO/OrderingGroup.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import io.vavr.collection.List; +import io.vavr.collection.Set; /** * The ordering of the pull request @@ -24,6 +25,7 @@ public class OrderingGroup { @JsonProperty("info") public final TitleDescription info; + @JsonProperty("isDefault") public final Boolean defaultGroup; @@ -59,6 +61,20 @@ public OrderingGroup(String title, String description, List hunks, } + public OrderingGroup intersect(List newhunkChecksums) { + Set a = this.hunkChecksums.toSortedSet(); + Set b = newhunkChecksums.toSortedSet(); + return new OrderingGroup(a.intersect(b).toList(), this.info); + } + + public Boolean isEmpty() { + return this.hunkChecksums.isEmpty(); + } + + public static OrderingGroup newDefaultGoup(List hunks) { + return new OrderingGroup("Default group", "Default group", hunks, true); + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/previewcode/backend/api/v1/WebhookAPI.java b/src/main/java/previewcode/backend/api/v1/WebhookAPI.java index ed0358e..3765161 100644 --- a/src/main/java/previewcode/backend/api/v1/WebhookAPI.java +++ b/src/main/java/previewcode/backend/api/v1/WebhookAPI.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.atlassian.fugue.Unit; +import io.vavr.collection.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import previewcode.backend.DTO.*; @@ -11,6 +12,7 @@ import previewcode.backend.services.GithubService; import previewcode.backend.services.IDatabaseService; import previewcode.backend.services.actiondsl.ActionDSL; +import previewcode.backend.services.actiondsl.ActionDSL.Action; import previewcode.backend.services.actiondsl.Interpreter; import previewcode.backend.services.interpreters.DatabaseInterpreter; @@ -74,14 +76,19 @@ public Response onWebhookPost( //Add hunks to database Diff diff = githubService.fetchDiff(repoAndPull.second); - OrderingGroup defaultGroup = new OrderingGroup("Default group", "Default group", diff.getHunkChecksums()); - ActionDSL.Action groupAction = databaseService.insertDefaultGroup(new PullRequestIdentifier(repoAndPull.first, repoAndPull.second), defaultGroup); - interpreter.evaluateToResponse(groupAction); + OrderingGroup defaultGroup = OrderingGroup.newDefaultGoup(diff.getHunkChecksums()); + Action groupAction = databaseService.insertGroup(new PullRequestIdentifier(repoAndPull.first, repoAndPull.second), defaultGroup); + return interpreter.evaluateToResponse(groupAction); } else if (action.equals("synchronize")) { Pair repoAndPull = readRepoAndPullFromWebhook(body); + + Diff diff = githubService.fetchDiff(repoAndPull.second); + List hunkChecksums = diff.getHunkChecksums(); + Action mergeWithExistingGroups = databaseService.mergeNewHunks(new PullRequestIdentifier(repoAndPull.first, repoAndPull.second), hunkChecksums); OrderingStatus pendingStatus = new OrderingStatus(repoAndPull.second); githubService.setOrderingStatus(repoAndPull.second, pendingStatus); + return interpreter.evaluateToResponse(mergeWithExistingGroups); } } else if (eventType.equals("pull_request_review")) { // Respond to a review event diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 6a7fd0c..257e449 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -5,10 +5,8 @@ import io.vavr.collection.List; import previewcode.backend.DTO.*; import previewcode.backend.database.*; -import previewcode.backend.database.model.tables.PullRequest; import previewcode.backend.services.actions.DatabaseActions; -import java.security.acl.Group; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; @@ -22,13 +20,31 @@ public class DatabaseService implements IDatabaseService { public Action updateOrdering(PullRequestIdentifier pull, List newGroups) { return insertPullIfNotExists(pull).then(pullID -> fetchGroups(pullID).then(existingGroups -> - traverse(newGroups, createGroup(pullID, false)).then( + traverse(newGroups, createGroup(pullID)).then( traverse(existingGroups, g -> delete(g.id)) ) ) ).toUnit(); } + public Action mergeNewHunks(PullRequestIdentifier pull, List newHunks) { + Function, List> merge = existingGroups -> + existingGroups.map(g -> g.intersect(newHunks)).filter(OrderingGroup::isEmpty); + + return insertPullIfNotExists(pull).then(pullID -> + fetchGroups(pullID).then(existingGroups -> + traverse(existingGroups, g -> fetchGroupOrdering(g).then(o -> delete(g.id).pure(o))) + .map(merge) + .then(newGroups -> traverse(newGroups, createGroup(pullID)).pure(newGroups)) + .map(newGroups -> + newHunks.removeAll(newGroups.flatMap(g -> g.hunkChecksums)) + ) + .map(OrderingGroup::newDefaultGoup) + .then(defaultGroup -> insertGroup(pull, defaultGroup)) + ) + ); + } + @Override public Action getOrdering(PullRequestIdentifier pull) { @@ -56,9 +72,9 @@ public Action fetchPullOrdering(List pullRequestGrou @Override - public Action insertDefaultGroup(PullRequestIdentifier pull, OrderingGroup group) { + public Action insertGroup(PullRequestIdentifier pull, OrderingGroup group) { return insertPullIfNotExists(pull) - .then(dbPullId -> createGroup(dbPullId, true).apply(group)).toUnit(); + .then(dbPullId -> createGroup(dbPullId).apply(group)).toUnit(); } @Override @@ -86,9 +102,9 @@ public Action getApproval(PullRequestIdentifier pull) { ); } - public Function> createGroup(PullRequestID dbPullId, Boolean defaultGroup) { + public Function> createGroup(PullRequestID dbPullId) { return group -> - newGroup(dbPullId, group.info.title, group.info.description, defaultGroup).then( + newGroup(dbPullId, group.info.title, group.info.description, group.defaultGroup).then( groupID -> traverse(List.ofAll(group.hunkChecksums), hunkId -> assignToGroup(groupID, hunkId.checksum)) ).toUnit(); } diff --git a/src/main/java/previewcode/backend/services/IDatabaseService.java b/src/main/java/previewcode/backend/services/IDatabaseService.java index 414b346..4f0c9a4 100644 --- a/src/main/java/previewcode/backend/services/IDatabaseService.java +++ b/src/main/java/previewcode/backend/services/IDatabaseService.java @@ -12,7 +12,9 @@ public interface IDatabaseService { Action getOrdering(PullRequestIdentifier pullRequestIdentifier); - Action insertDefaultGroup(PullRequestIdentifier pullRequestIdentifier, OrderingGroup body); + Action mergeNewHunks(PullRequestIdentifier pull, List newHunks); + + Action insertGroup(PullRequestIdentifier pullRequestIdentifier, OrderingGroup body); Action setApproval(PullRequestIdentifier pullRequestIdentifier, ApproveRequest approval); diff --git a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java index 6c475e0..e7ee3a1 100644 --- a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java +++ b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java @@ -89,7 +89,7 @@ public Interpreter() { public Interpreter(Interpreter ... interpreters) { this(); List.of(interpreters).forEach(interpreter -> interpreter.handlers.forEach(((actionClass, __) -> { - handlers.put(actionClass, (Function, Object>) interpreter::unsafeEvaluate); + handlers.put(actionClass, (CheckedFunction1, Object>) interpreter::unsafeEvaluate); }))); } diff --git a/src/test/java/previewcode/backend/api/v2/EndPointTest.java b/src/test/java/previewcode/backend/api/v2/EndPointTest.java index 267de17..8e897ab 100644 --- a/src/test/java/previewcode/backend/api/v2/EndPointTest.java +++ b/src/test/java/previewcode/backend/api/v2/EndPointTest.java @@ -107,7 +107,7 @@ public Action updateOrdering(PullRequestIdentifier pullRequestIdentifier, } @Override - public Action insertDefaultGroup(PullRequestIdentifier pullRequestIdentifier, OrderingGroup body) { + public Action insertGroup(PullRequestIdentifier pullRequestIdentifier, OrderingGroup body) { return new NoOp<>(); } diff --git a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java index 5edf8b8..cfd596e 100644 --- a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java +++ b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java @@ -9,7 +9,6 @@ import previewcode.backend.DTO.*; import previewcode.backend.database.*; import previewcode.backend.DTO.HunkChecksum; -import previewcode.backend.database.model.tables.PullRequest; import previewcode.backend.services.actiondsl.Interpreter; import java.util.Collection; @@ -178,7 +177,7 @@ public void updateOrdering_insertsNewGroupsWithHunks() { public void insertsDefaultGroup() throws Exception { PullRequestGroup group = new PullRequestGroup(new GroupID(42L), "Group A", "Description A", true); OrderingGroup defaultGroup = new OrderingGroupWithID(group, hunkIDs.map(id -> id)); - Action dbAction = service.insertDefaultGroup(pullIdentifier, defaultGroup); + Action dbAction = service.insertGroup(pullIdentifier, defaultGroup); Collection groupsAdded = Lists.newArrayList(); From 0e41216da5d2d42bac0c1cf02c0d87571e1f7cc7 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Tue, 7 Nov 2017 11:24:43 +0100 Subject: [PATCH 16/22] Fix default group when updating an ordering --- .../backend/services/DatabaseService.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 257e449..418d44b 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -20,13 +20,23 @@ public class DatabaseService implements IDatabaseService { public Action updateOrdering(PullRequestIdentifier pull, List newGroups) { return insertPullIfNotExists(pull).then(pullID -> fetchGroups(pullID).then(existingGroups -> - traverse(newGroups, createGroup(pullID)).then( - traverse(existingGroups, g -> delete(g.id)) - ) + traverse(newGroups, createGroup(pullID)) + .then(deriveDefaultGroup(existingGroups, newGroups) + .then(createGroup(pullID))) + .then(traverse(existingGroups, g -> delete(g.id))) ) ).toUnit(); } + private Action deriveDefaultGroup(List existingGroups, List newGroups) { + List newHunks = newGroups.flatMap(orderingGroup -> orderingGroup.hunkChecksums); + return traverse(existingGroups, g -> g.fetchHunks) + .map(flatten()) + .map(hunks -> hunks.map(hunk -> hunk.checksum)) + .map(existingHunks -> existingHunks.removeAll(newHunks)) + .map(OrderingGroup::newDefaultGoup); + } + public Action mergeNewHunks(PullRequestIdentifier pull, List newHunks) { Function, List> merge = existingGroups -> existingGroups.map(g -> g.intersect(newHunks)).filter(OrderingGroup::isEmpty); From c198b53afebd550abe20704b5e7ee683db8dfe99 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Tue, 7 Nov 2017 11:38:00 +0100 Subject: [PATCH 17/22] Only create default group if it's not empty --- .../java/previewcode/backend/services/DatabaseService.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 418d44b..683e731 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -22,7 +22,10 @@ public Action updateOrdering(PullRequestIdentifier pull, List traverse(newGroups, createGroup(pullID)) .then(deriveDefaultGroup(existingGroups, newGroups) - .then(createGroup(pullID))) + .then(defaultGroup -> { + if (!defaultGroup.isEmpty()) return createGroup(pullID).apply(defaultGroup); + else return pure(unit); + })) .then(traverse(existingGroups, g -> delete(g.id))) ) ).toUnit(); @@ -50,7 +53,7 @@ public Action mergeNewHunks(PullRequestIdentifier pull, List newHunks.removeAll(newGroups.flatMap(g -> g.hunkChecksums)) ) .map(OrderingGroup::newDefaultGoup) - .then(defaultGroup -> insertGroup(pull, defaultGroup)) + .then(defaultGroup -> createGroup(pullID).apply(defaultGroup)) ) ); } From 9eb5bd2f9869465dd0bed6b043e72bd00020e102 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Tue, 7 Nov 2017 12:06:03 +0100 Subject: [PATCH 18/22] Changed a few small things (#62) --- src/main/java/previewcode/backend/DTO/OrderingGroup.java | 9 +++++++-- .../previewcode/backend/services/DatabaseService.java | 6 +++--- .../backend/services/actiondsl/Interpreter.java | 3 ++- .../services/interpreters/DatabaseInterpreter.java | 1 + .../services/interpreters/GitHubAuthInterpreter.java | 4 ++-- .../java/previewcode/backend/api/v2/EndPointTest.java | 5 +++++ 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroup.java b/src/main/java/previewcode/backend/DTO/OrderingGroup.java index ce30b2b..c0d9867 100644 --- a/src/main/java/previewcode/backend/DTO/OrderingGroup.java +++ b/src/main/java/previewcode/backend/DTO/OrderingGroup.java @@ -51,13 +51,18 @@ public OrderingGroup(String title, String description, List hunks) this.defaultGroup = false; } - public OrderingGroup(String title, String description, List hunks, boolean defaultGroup) { + public OrderingGroup(String title, String description, List hunks, Boolean defaultGroup) { this.hunkChecksums = hunks; if(title == null){ title = ""; } + + if(defaultGroup == null) { + this.defaultGroup = false; + } else { + this.defaultGroup = defaultGroup; + } this.info = new TitleDescription(title, description); - this.defaultGroup = defaultGroup; } diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 683e731..e3bda24 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -21,12 +21,12 @@ public Action updateOrdering(PullRequestIdentifier pull, List fetchGroups(pullID).then(existingGroups -> traverse(newGroups, createGroup(pullID)) - .then(deriveDefaultGroup(existingGroups, newGroups) - .then(defaultGroup -> { + .then(deriveDefaultGroup(existingGroups, newGroups) + .then(defaultGroup -> traverse(existingGroups, g -> delete(g.id)).pure(defaultGroup)) + .then(defaultGroup -> { if (!defaultGroup.isEmpty()) return createGroup(pullID).apply(defaultGroup); else return pure(unit); })) - .then(traverse(existingGroups, g -> delete(g.id))) ) ).toUnit(); } diff --git a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java index e7ee3a1..125772b 100644 --- a/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java +++ b/src/main/java/previewcode/backend/services/actiondsl/Interpreter.java @@ -6,6 +6,7 @@ import io.vavr.CheckedFunction1; import io.vavr.collection.List; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.util.HashMap; import java.util.Map; @@ -170,7 +171,7 @@ private static R sneakyThrow(Throwable t) throws T { * @return The response built from the action result. */ public Response evaluateToResponse(Action action) { - return Response.ok().entity(unsafeEvaluate(action)).build(); + return Response.ok().type(MediaType.APPLICATION_JSON_TYPE).entity(unsafeEvaluate(action)).build(); } protected Try run(Action action) { diff --git a/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java b/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java index f34f085..b080430 100644 --- a/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java +++ b/src/main/java/previewcode/backend/services/interpreters/DatabaseInterpreter.java @@ -124,6 +124,7 @@ protected PullRequestID fetchPullRequest(FetchPull action) { protected GroupID insertNewGroup(NewGroup newGroup) { Boolean defaultGroup = newGroup.defaultGroup; + //Needed for the uniqueness constraint, as there should only be one default group if(!newGroup.defaultGroup) { defaultGroup = null; } diff --git a/src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java b/src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java index c5f294e..c0d9706 100644 --- a/src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java +++ b/src/main/java/previewcode/backend/services/interpreters/GitHubAuthInterpreter.java @@ -74,8 +74,8 @@ public GitHubAuthInterpreter( } public static ActionCache.Builder configure(ActionCache.Builder b) { - return b.expire(GetUser.class).afterWrite(15, TimeUnit.MINUTES) - .expire(AuthenticateInstallation.class).afterWrite(1, TimeUnit.HOURS); + return b.expire(GetUser.class).afterWrite(15, TimeUnit.MINUTES); + //.expire(AuthenticateInstallation.class).afterWrite(1, TimeUnit.HOURS); } protected Unit authInstallation(AuthenticateInstallation action) throws IOException { diff --git a/src/test/java/previewcode/backend/api/v2/EndPointTest.java b/src/test/java/previewcode/backend/api/v2/EndPointTest.java index 8e897ab..a189199 100644 --- a/src/test/java/previewcode/backend/api/v2/EndPointTest.java +++ b/src/test/java/previewcode/backend/api/v2/EndPointTest.java @@ -9,6 +9,7 @@ import previewcode.backend.APIModule; import previewcode.backend.DTO.*; import previewcode.backend.services.IGithubService; +import previewcode.backend.services.actiondsl.ActionDSL; import previewcode.backend.services.actiondsl.Interpreter; import previewcode.backend.test.helpers.ApiEndPointTest; import previewcode.backend.database.PullRequestGroup; @@ -131,6 +132,10 @@ public Action getOrdering(PullRequestIdentifier pull) { return new NoOp<>(); } + @Override + public Action mergeNewHunks(PullRequestIdentifier pull, List newHunks) { + return new NoOp<>(); + } @Override From f2dd398e44472a484f5e564cd672131381896d0c Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Tue, 7 Nov 2017 13:57:25 +0100 Subject: [PATCH 19/22] Add new tests for updating ordering --- .../backend/DTO/OrderingGroup.java | 12 +- .../backend/DTO/OrderingGroupWithID.java | 23 ---- .../backend/services/DatabaseService.java | 17 ++- .../backend/services/DatabaseServiceTest.java | 129 +++++++++++++----- 4 files changed, 111 insertions(+), 70 deletions(-) delete mode 100644 src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroup.java b/src/main/java/previewcode/backend/DTO/OrderingGroup.java index c0d9867..e9f3a0e 100644 --- a/src/main/java/previewcode/backend/DTO/OrderingGroup.java +++ b/src/main/java/previewcode/backend/DTO/OrderingGroup.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.vavr.collection.List; import io.vavr.collection.Set; +import previewcode.backend.database.PullRequestGroup; /** * The ordering of the pull request @@ -43,12 +44,11 @@ public OrderingGroup( } public OrderingGroup(String title, String description, List hunks) { - this.hunkChecksums = hunks; - if(title == null){ - title = ""; - } - this.info = new TitleDescription(title, description); - this.defaultGroup = false; + this(title, description, hunks, false); + } + + public OrderingGroup(PullRequestGroup dbGroup, List hunkIds) { + this(dbGroup.title, dbGroup.description, hunkIds, dbGroup.defaultGroup); } public OrderingGroup(String title, String description, List hunks, Boolean defaultGroup) { diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java deleted file mode 100644 index 21d92a6..0000000 --- a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java +++ /dev/null @@ -1,23 +0,0 @@ -package previewcode.backend.DTO; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import io.vavr.collection.List; -import previewcode.backend.database.PullRequestGroup; - - -@JsonIgnoreProperties(ignoreUnknown=true) -public class OrderingGroupWithID extends OrderingGroup { - - /** - * The id of the group - */ - @JsonProperty("id") - public final String id; - - - public OrderingGroupWithID(PullRequestGroup dbGroup, List hunkIds) { - super(dbGroup.title, dbGroup.description, hunkIds); - this.id = dbGroup.id.id.toString(); - } -} diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index e3bda24..9c94c6d 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -21,12 +21,14 @@ public Action updateOrdering(PullRequestIdentifier pull, List fetchGroups(pullID).then(existingGroups -> traverse(newGroups, createGroup(pullID)) - .then(deriveDefaultGroup(existingGroups, newGroups) - .then(defaultGroup -> traverse(existingGroups, g -> delete(g.id)).pure(defaultGroup)) - .then(defaultGroup -> { - if (!defaultGroup.isEmpty()) return createGroup(pullID).apply(defaultGroup); - else return pure(unit); - })) + .then(deriveDefaultGroup(existingGroups, newGroups) + .then(defaultGroup -> traverse(existingGroups, g -> delete(g.id)).pure(defaultGroup)) + .then(defaultGroup -> { + if (!defaultGroup.isEmpty()) + return createGroup(pullID).apply(defaultGroup); + else + return pure(unit); + })) ) ).toUnit(); } @@ -36,7 +38,8 @@ private Action deriveDefaultGroup(List existing return traverse(existingGroups, g -> g.fetchHunks) .map(flatten()) .map(hunks -> hunks.map(hunk -> hunk.checksum)) - .map(existingHunks -> existingHunks.removeAll(newHunks)) + .map(existingHunks -> + existingHunks.removeAll(newHunks)) .map(OrderingGroup::newDefaultGoup); } diff --git a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java index cfd596e..eb06fe6 100644 --- a/src/test/java/previewcode/backend/services/DatabaseServiceTest.java +++ b/src/test/java/previewcode/backend/services/DatabaseServiceTest.java @@ -35,25 +35,25 @@ public class DatabaseServiceTest { private PullRequestIdentifier pullIdentifier = new PullRequestIdentifier(owner, name, number); private Boolean defaultGroup = false; - private PullRequestGroup groupDefault = new PullRequestGroup(new GroupID(42L), "Group A", "Description A", true); + private PullRequestGroup groupDefault = new PullRequestGroup(new GroupID(42L), "Default group", "Description A", true); private PullRequestGroup groupOther = new PullRequestGroup(new GroupID(24L), "Group B", "Description B", false); private PullRequestGroup groupOtherOne = new PullRequestGroup(new GroupID(21L), "Group C", "Description C", false); private List groups = List.of(groupDefault,groupOther,groupOtherOne); - HunkChecksum checksum = new HunkChecksum("abcd"); + HunkChecksum checksum = new HunkChecksum("xyz"); HunkID id = new HunkID(1L); List oneHunk = List.of(new Hunk(id, new GroupID(2L), checksum)); - private List groupsWithoutHunks= groups.map(group -> - new OrderingGroupWithID(group, List.empty()) + private List groupsWithoutHunks = groups.map(group -> + new OrderingGroup(group, List.empty()) ); private List hunkIDs = List.of( new HunkChecksum("abcd"), new HunkChecksum("efgh"), new HunkChecksum("ijkl")); - private List groupsWithHunks = groups.map(group -> - new OrderingGroupWithID(group, hunkIDs.map(id -> id)) + private List groupsWithHunks = List.of(groupOther,groupOtherOne).map(group -> + new OrderingGroup(group, hunkIDs) ); private ApproveRequest approveStatus = new ApproveRequest("checksum", ApproveStatus.DISAPPROVED, "txsmith"); @@ -79,15 +79,29 @@ public void updateOrdering_insertsPullIfNotExists() { void updateOrdering_firstInsertsNewGroups() { Action dbAction = service.updateOrdering(pullIdentifier, groupsWithoutHunks); - class DoneException extends RuntimeException {} + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(NewGroup.class).stop(); + + assertThatExceptionOfType(Interpreter.StoppedException.class) + .isThrownBy(() -> interpreter.unsafeEvaluate(dbAction)); + } + + @Test + public void updateOrdering_fetchesAllCurrentHunks() { + Action dbAction = service.updateOrdering(pullIdentifier, List.empty()); Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(groups) - .on(NewGroup.class).apply(action -> { throw new DoneException(); }); + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.of(groupDefault)) + .on(FetchHunksForGroup.class).stop(fetchHunksForGroup -> + assertThat(groupDefault.id).isEqualTo(fetchHunksForGroup.groupID) + ); - assertThatExceptionOfType(DoneException.class) + assertThatExceptionOfType(Interpreter.StoppedException.class) .isThrownBy(() -> interpreter.unsafeEvaluate(dbAction)); } @@ -99,9 +113,10 @@ public void updateOrdering_removesExistingGroups() { Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(groups) - .on(DeleteGroup.class).apply(toUnit(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(groups) + .on(FetchHunksForGroup.class).apply(__ -> List.empty()) + .on(DeleteGroup.class).apply(toUnit(action -> { assertThat(groups).extracting("id").contains(action.groupID); removedGroups.add(groups.find(group -> group.id.equals(action.groupID)).get()); })); @@ -118,8 +133,8 @@ public void updateOrdering_doesNotRemoveGroups_whenThereAreNone() { Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(List.empty()); + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()); interpreter.unsafeEvaluate(dbAction); } @@ -132,9 +147,9 @@ public void updateOrdering_insertsNewGroupsWithoutHunks() { Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(List.empty()) - .on(NewGroup.class).apply(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()) + .on(NewGroup.class).apply(action -> { assertThat(action.pullRequestId).isEqualTo(pullRequestID); PullRequestGroup group = groups.find(g -> g.title.equals(action.title)).get(); assertThat(group.description).isEqualTo(action.description); @@ -156,11 +171,11 @@ public void updateOrdering_insertsNewGroupsWithHunks() { Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(FetchGroupsForPull.class).returnA(List.empty()) - .on(NewGroup.class).apply(action -> - groups.find(g -> g.title.equals(action.title)).get().id) - .on(AssignHunkToGroup.class).apply(toUnit(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.empty()) + .on(NewGroup.class).apply(action -> + groups.find(g -> g.title.equals(action.title)).get().id) + .on(AssignHunkToGroup.class).apply(toUnit(action -> { assertThat(groups.find(g -> g.id.equals(action.groupID))).isNotEmpty(); Option hunkID = hunkIDs.find(id -> id.checksum.equals(action.hunkChecksum)); assertThat(hunkID).isNotEmpty(); @@ -173,23 +188,69 @@ public void updateOrdering_insertsNewGroupsWithHunks() { .hasSize(hunkIDs.size() * groupsWithHunks.size()); } + @Test + public void updateOrdering_createsDefaultGroup() { + Action dbAction = service.updateOrdering(pullIdentifier, groupsWithHunks); + + Collection hunksAdded = Lists.newArrayList(); + Collection groupsAdded = Lists.newArrayList(); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.of(groupDefault)) + .on(FetchHunksForGroup.class).apply(__ -> oneHunk) + .on(DeleteGroup.class).returnA(unit) + .on(NewGroup.class).apply(action -> { + GroupID id = groups.find(g -> g.title.equals(action.title)).get().id; + groupsAdded.add(id); + return id; + }) + .on(AssignHunkToGroup.class).returnA(unit); + + interpreter.unsafeEvaluate(dbAction); + + assertThat(groupsAdded).contains(groupDefault.id); + } + + @Test + public void updateOrdering_createsDefaultGroup_2() { + Action dbAction = service.updateOrdering(pullIdentifier, groupsWithHunks); + + Interpreter interpreter = + interpret() + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(FetchGroupsForPull.class).returnA(List.of(groupDefault)) + .on(FetchHunksForGroup.class).apply(__ -> oneHunk) + .on(DeleteGroup.class).returnA(unit) + .on(NewGroup.class).apply(action -> groups.find(g -> g.title.equals(action.title)).get().id) + .on(AssignHunkToGroup.class).apply(action -> { + if (action.hunkChecksum.equals(oneHunk.head().checksum.checksum)) { + assertThat(action.groupID).isEqualTo(groupDefault.id); + } + return unit; + }); + + interpreter.unsafeEvaluate(dbAction); + } + @Test public void insertsDefaultGroup() throws Exception { PullRequestGroup group = new PullRequestGroup(new GroupID(42L), "Group A", "Description A", true); - OrderingGroup defaultGroup = new OrderingGroupWithID(group, hunkIDs.map(id -> id)); + OrderingGroup defaultGroup = new OrderingGroup(group, hunkIDs); Action dbAction = service.insertGroup(pullIdentifier, defaultGroup); Collection groupsAdded = Lists.newArrayList(); Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(NewGroup.class).apply(action -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(NewGroup.class).apply(action -> { assertThat(action.defaultGroup).isEqualTo(true); groupsAdded.add(group); return group.id; }) - .on(AssignHunkToGroup.class).apply(toUnit(action -> { + .on(AssignHunkToGroup.class).apply(toUnit(action -> { assertThat(List.of(group).find(g -> g.id.equals(action.groupID))).isNotEmpty(); Option hunkID = hunkIDs.find(id -> id.checksum.equals(action.hunkChecksum)); assertThat(hunkID).isNotEmpty(); @@ -207,8 +268,8 @@ public void insertApproval() { Interpreter interpreter = interpret() - .on(InsertPullIfNotExists.class).returnA(pullRequestID) - .on(ApproveHunk.class).stop(approveHunk -> { + .on(InsertPullIfNotExists.class).returnA(pullRequestID) + .on(ApproveHunk.class).stop(approveHunk -> { assertThat(approveHunk.status) .isEqualTo(ApproveStatus.DISAPPROVED); assertThat(approveHunk.githubUser) @@ -293,7 +354,7 @@ void getOrdering_fetches_pull_pullRequest() { @Test - void getOrdering_fetches_pull_groups(){ + void getOrdering_fetches_pull_groups() { Action dbAction = service.getOrdering(pullIdentifier); Interpreter.Stepper stepper = interpret() @@ -305,7 +366,7 @@ void getOrdering_fetches_pull_groups(){ @Test - void getOrdering_fetches_hunks(){ + void getOrdering_fetches_hunks() { Action dbAction = service.getOrdering(pullIdentifier); List oneGroup = List.of( @@ -322,10 +383,10 @@ void getOrdering_fetches_hunks(){ } @Test - void getOrdering_fetches_ordering(){ + void getOrdering_fetches_ordering() { Action dbAction = service.getOrdering(pullIdentifier); - OrderingGroup defaultOrderingGroup = new OrderingGroup("Group A", "Description A", List.of(checksum), true); + OrderingGroup defaultOrderingGroup = new OrderingGroup("Default group", "Description A", List.of(checksum), true); OrderingGroup firstGroup = new OrderingGroup("Group B", "Description B", List.of(checksum), false); OrderingGroup secondGroup = new OrderingGroup("Group C", "Description C", List.of(checksum), false); From 28863b229a647511a066bae4572e7c8340c2acca Mon Sep 17 00:00:00 2001 From: Eva Anker Date: Fri, 10 Nov 2017 11:00:41 +0000 Subject: [PATCH 20/22] Fixes the no such element exception (#63) --- .../backend/services/DatabaseService.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 9c94c6d..8a36ebf 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Map; +import java.util.NoSuchElementException; import java.util.function.Function; import static previewcode.backend.services.actiondsl.ActionDSL.*; import static previewcode.backend.services.actions.DatabaseActions.*; @@ -77,9 +78,15 @@ public Action fetchGroupOrdering(PullRequestGroup group) { } public Ordering createOrdering(List orderingGroups) { - return new Ordering( - orderingGroups.find(group -> group.defaultGroup).get(), - orderingGroups.filter(group -> !group.defaultGroup)); + List nonDefaultGroups = orderingGroups.filter(group -> !group.defaultGroup); + try { + return new Ordering( + orderingGroups.find(group -> group.defaultGroup).get(), + nonDefaultGroups); + } catch (NoSuchElementException noSuchElementException) { + return new Ordering(null, + nonDefaultGroups); + } } public Action fetchPullOrdering(List pullRequestGroups) { From bcbbb147db848128498684f81ad5e2e39899fcd2 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Fri, 10 Nov 2017 15:13:51 +0100 Subject: [PATCH 21/22] Group ordering (#65) * Add ID to OrderingGroup fetched from the database * Renamed the groupid, to be consistent with frontend --- .../backend/DTO/OrderingGroupWithID.java | 37 +++++++++++++++++++ .../backend/services/DatabaseService.java | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java diff --git a/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java new file mode 100644 index 0000000..eb631d4 --- /dev/null +++ b/src/main/java/previewcode/backend/DTO/OrderingGroupWithID.java @@ -0,0 +1,37 @@ +package previewcode.backend.DTO; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.vavr.collection.List; +import previewcode.backend.database.PullRequestGroup; + +public class OrderingGroupWithID extends OrderingGroup { + + @JsonProperty("id") + public final Long groupID; + + public OrderingGroupWithID(List hunkChecksums, TitleDescription info) { + super(hunkChecksums, info); + this.groupID = 0L; + } + + public OrderingGroupWithID(String title, String description, List hunks) { + super(title, description, hunks); + this.groupID = 0L; + } + + public OrderingGroupWithID(PullRequestGroup dbGroup, List hunkIds) { + super(dbGroup, hunkIds); + this.groupID = 0L; + } + + public OrderingGroupWithID(String title, String description, List hunks, Boolean defaultGroup) { + super(title, description, hunks, defaultGroup); + this.groupID = 0L; + } + + public OrderingGroupWithID(Long groupID, String title, String description, List hunks, Boolean defaultGroup) { + super(title, description, hunks, defaultGroup); + this.groupID = groupID; + } + +} diff --git a/src/main/java/previewcode/backend/services/DatabaseService.java b/src/main/java/previewcode/backend/services/DatabaseService.java index 8a36ebf..92f6c32 100644 --- a/src/main/java/previewcode/backend/services/DatabaseService.java +++ b/src/main/java/previewcode/backend/services/DatabaseService.java @@ -74,7 +74,7 @@ public Action> fetchGroupHunks(PullRequestGroup group) { public Action fetchGroupOrdering(PullRequestGroup group) { return fetchGroupHunks(group).map(hunkChecksums -> - new OrderingGroup(group.title, group.description, hunkChecksums, group.defaultGroup)); + new OrderingGroupWithID(group.id.id, group.title, group.description, hunkChecksums, group.defaultGroup)); } public Ordering createOrdering(List orderingGroups) { From f360a6458cd04e038294a4cb7a49bdf295ed7759 Mon Sep 17 00:00:00 2001 From: Thomas Smith Date: Fri, 10 Nov 2017 15:37:28 +0100 Subject: [PATCH 22/22] Set all API paths to v1 (#66) --- src/main/java/previewcode/backend/APIModule.java | 6 +++--- .../backend/api/{v2 => v1}/ApprovalsAPI.java | 4 ++-- .../backend/api/{v2 => v1}/OrderingAPI.java | 4 ++-- .../previewcode/backend/api/{v2 => v1}/TestAPI.java | 6 +++--- .../backend/api/{v2 => v1}/EndPointTest.java | 13 ++++++------- .../RequestContextActionInterpreterTest.java | 2 +- .../{v2 => v1}/WrapppedTypeDeserializingTest.java | 2 +- 7 files changed, 18 insertions(+), 19 deletions(-) rename src/main/java/previewcode/backend/api/{v2 => v1}/ApprovalsAPI.java (97%) rename src/main/java/previewcode/backend/api/{v2 => v1}/OrderingAPI.java (95%) rename src/main/java/previewcode/backend/api/{v2 => v1}/TestAPI.java (88%) rename src/test/java/previewcode/backend/api/{v2 => v1}/EndPointTest.java (92%) rename src/test/java/previewcode/backend/api/{v2 => v1}/RequestContextActionInterpreterTest.java (99%) rename src/test/java/previewcode/backend/api/{v2 => v1}/WrapppedTypeDeserializingTest.java (95%) diff --git a/src/main/java/previewcode/backend/APIModule.java b/src/main/java/previewcode/backend/APIModule.java index 85c2f12..f19f50f 100644 --- a/src/main/java/previewcode/backend/APIModule.java +++ b/src/main/java/previewcode/backend/APIModule.java @@ -10,9 +10,9 @@ import io.vavr.jackson.datatype.VavrModule; import org.jboss.resteasy.plugins.guice.ext.JaxrsModule; import previewcode.backend.api.exceptionmapper.*; -import previewcode.backend.api.v2.ApprovalsAPI; -import previewcode.backend.api.v2.OrderingAPI; -import previewcode.backend.api.v2.TestAPI; +import previewcode.backend.api.v1.ApprovalsAPI; +import previewcode.backend.api.v1.OrderingAPI; +import previewcode.backend.api.v1.TestAPI; import javax.ws.rs.ext.ContextResolver; import javax.ws.rs.ext.Provider; diff --git a/src/main/java/previewcode/backend/api/v2/ApprovalsAPI.java b/src/main/java/previewcode/backend/api/v1/ApprovalsAPI.java similarity index 97% rename from src/main/java/previewcode/backend/api/v2/ApprovalsAPI.java rename to src/main/java/previewcode/backend/api/v1/ApprovalsAPI.java index 9c262cb..f50225b 100644 --- a/src/main/java/previewcode/backend/api/v2/ApprovalsAPI.java +++ b/src/main/java/previewcode/backend/api/v1/ApprovalsAPI.java @@ -1,4 +1,4 @@ -package previewcode.backend.api.v2; +package previewcode.backend.api.v1; import com.google.inject.Inject; import io.atlassian.fugue.Unit; @@ -18,7 +18,7 @@ /** * API for getting and setting the approvals on a pullrequest */ -@Path("v2/{owner}/{name}/pulls/{number}/") +@Path("v1/{owner}/{name}/pulls/{number}/") public class ApprovalsAPI { private Interpreter interpreter; diff --git a/src/main/java/previewcode/backend/api/v2/OrderingAPI.java b/src/main/java/previewcode/backend/api/v1/OrderingAPI.java similarity index 95% rename from src/main/java/previewcode/backend/api/v2/OrderingAPI.java rename to src/main/java/previewcode/backend/api/v1/OrderingAPI.java index 23b149a..752fdac 100644 --- a/src/main/java/previewcode/backend/api/v2/OrderingAPI.java +++ b/src/main/java/previewcode/backend/api/v1/OrderingAPI.java @@ -1,4 +1,4 @@ -package previewcode.backend.api.v2; +package previewcode.backend.api.v1; import com.google.inject.Inject; import io.atlassian.fugue.Unit; @@ -18,7 +18,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.core.Response; -@Path("v2/{owner}/{name}/pulls/{number}/ordering") +@Path("v1/{owner}/{name}/pulls/{number}/ordering") public class OrderingAPI { private final Interpreter interpreter; diff --git a/src/main/java/previewcode/backend/api/v2/TestAPI.java b/src/main/java/previewcode/backend/api/v1/TestAPI.java similarity index 88% rename from src/main/java/previewcode/backend/api/v2/TestAPI.java rename to src/main/java/previewcode/backend/api/v1/TestAPI.java index 7a37768..635f61e 100644 --- a/src/main/java/previewcode/backend/api/v2/TestAPI.java +++ b/src/main/java/previewcode/backend/api/v1/TestAPI.java @@ -1,4 +1,4 @@ -package previewcode.backend.api.v2; +package previewcode.backend.api.v1; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -8,12 +8,12 @@ import javax.ws.rs.Produces; import java.util.Date; -@Path("v2/test") +@Path("v1/test") public class TestAPI { static class Response { @JsonProperty("version") - public String apiVersion = "v2"; + public String apiVersion = "v1"; @JsonProperty("time") public String serverTime = new Date().toString(); diff --git a/src/test/java/previewcode/backend/api/v2/EndPointTest.java b/src/test/java/previewcode/backend/api/v1/EndPointTest.java similarity index 92% rename from src/test/java/previewcode/backend/api/v2/EndPointTest.java rename to src/test/java/previewcode/backend/api/v1/EndPointTest.java index a189199..70cb783 100644 --- a/src/test/java/previewcode/backend/api/v2/EndPointTest.java +++ b/src/test/java/previewcode/backend/api/v1/EndPointTest.java @@ -1,4 +1,4 @@ -package previewcode.backend.api.v2; +package previewcode.backend.api.v1; import com.google.inject.name.Names; import io.atlassian.fugue.Try; @@ -9,7 +9,6 @@ import previewcode.backend.APIModule; import previewcode.backend.DTO.*; import previewcode.backend.services.IGithubService; -import previewcode.backend.services.actiondsl.ActionDSL; import previewcode.backend.services.actiondsl.Interpreter; import previewcode.backend.test.helpers.ApiEndPointTest; import previewcode.backend.database.PullRequestGroup; @@ -31,20 +30,20 @@ public class EndPointTest { @Test public void testApiIsReachable(WebTarget target) { Response response = target - .path("/v2/test") + .path("/v1/test") .request("application/json") .get(); assertThat(response.getStatus()).isEqualTo(200); TestAPI.Response apiResponse = response.readEntity(TestAPI.Response.class); - assertThat(apiResponse.apiVersion).isEqualTo("v2"); + assertThat(apiResponse.apiVersion).isEqualTo("v1"); } @Test public void orderingApiIsReachable(WebTarget target) { Response response = target - .path("/v2/preview-code/backend/pulls/42/ordering") + .path("/v1/preview-code/backend/pulls/42/ordering") .request("application/json") .post(Entity.json(new ArrayList<>())); @@ -55,7 +54,7 @@ public void orderingApiIsReachable(WebTarget target) { @Test public void setApprovedApiIsReachable(WebTarget target) { Response response = target - .path("/v2/preview-code/backend/pulls/42/setApprove") + .path("/v1/preview-code/backend/pulls/42/setApprove") .request("application/json") .post(Entity.json("{}")); @@ -66,7 +65,7 @@ public void setApprovedApiIsReachable(WebTarget target) { @Test public void getApprovalsApiIsReachable(WebTarget target) { Response response = target - .path("/v2/preview-code/backend/pulls/42/getApprovals") + .path("/v1/preview-code/backend/pulls/42/getApprovals") .request("application/json") .get(); diff --git a/src/test/java/previewcode/backend/api/v2/RequestContextActionInterpreterTest.java b/src/test/java/previewcode/backend/api/v1/RequestContextActionInterpreterTest.java similarity index 99% rename from src/test/java/previewcode/backend/api/v2/RequestContextActionInterpreterTest.java rename to src/test/java/previewcode/backend/api/v1/RequestContextActionInterpreterTest.java index b0d30c9..4d99305 100644 --- a/src/test/java/previewcode/backend/api/v2/RequestContextActionInterpreterTest.java +++ b/src/test/java/previewcode/backend/api/v1/RequestContextActionInterpreterTest.java @@ -1,4 +1,4 @@ -package previewcode.backend.api.v2; +package previewcode.backend.api.v1; import com.fasterxml.jackson.databind.JsonNode; import io.vavr.control.Option; diff --git a/src/test/java/previewcode/backend/api/v2/WrapppedTypeDeserializingTest.java b/src/test/java/previewcode/backend/api/v1/WrapppedTypeDeserializingTest.java similarity index 95% rename from src/test/java/previewcode/backend/api/v2/WrapppedTypeDeserializingTest.java rename to src/test/java/previewcode/backend/api/v1/WrapppedTypeDeserializingTest.java index 77fc73f..24afb13 100644 --- a/src/test/java/previewcode/backend/api/v2/WrapppedTypeDeserializingTest.java +++ b/src/test/java/previewcode/backend/api/v1/WrapppedTypeDeserializingTest.java @@ -1,4 +1,4 @@ -package previewcode.backend.api.v2; +package previewcode.backend.api.v1; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test;