diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c178e8b..86b66c4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,17 +19,12 @@ jobs: - name: Run chmod to make gradlew executable run: chmod +x gradlew - - name: Run tests + - name: Execute tests + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} uses: gradle/gradle-build-action@v3 with: - arguments: cleanTest test -# Configuration for SonarCloud, enable this when SonarCloud is configured -# - name: Execute tests -# env: -# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# uses: gradle/gradle-build-action@v3 -# with: -# arguments: cleanTest test jacocoTestReport sonar + arguments: cleanTest test jacocoTestReport sonar diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c6842ea..0000000 --- a/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# syntax=docker/dockerfile:experimental -FROM gradle:7.6.2-jdk17 AS build -WORKDIR /workspace/app - -# Copy only gradle files to container so we can install them -COPY ./build.gradle ./settings.gradle /workspace/app/ - -# install dependencies. This will be cached by the docker layered cache. This command will fail because the -# app code is still missing, so we return 0 so docker thinks the command executed successfully (but the -# dependencies are still downloaded even if the command fails so now we have them cached) -RUN gradle clean build -x test || return 0 - -# Now copy the actual app code and build it -COPY . /workspace/app -RUN gradle clean build -x test -RUN mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*-SNAPSHOT.jar) - -FROM eclipse-temurin:17-jdk -VOLUME /tmp -ARG DEPENDENCY=/workspace/app/build/dependency -COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib -COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF -COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app -ENTRYPOINT ["java","-cp","app:app/lib/*","de.unistuttgart.iste.meitrex.template.TemplateForMicroservicesApplication"] \ No newline at end of file diff --git a/README.md b/README.md index 47aa8a7..f180f56 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,26 @@ -# template-microservice -This serves as a template for the microservices +# Gropius Adapter for DinoDev -## Package structure +This repository contains the adapter that connects DinoDev to Gropius. -This package structure is based on multiple sources of best practices in Spring Boot, using roughly the "Package by layer" approach. -- *root* - - *config* - - *controller* - - *dapr* - - *dto* - - *exception* - - *persistence* - - *entity* - - *repository* - - *mapper* - - *service* - - *util* (optional, if needed) - - *validation* +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=MEITREX_dinodev_gropius_adapter&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=MEITREX_dinodev_gropius_adapter) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=MEITREX_dinodev_gropius_adapter&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=MEITREX_dinodev_gropius_adapter) -Detailed description of the packages: +## GraphQL Code Generator -### Root package +Like all other DinoDev repos, the adapter uses the +[GraphQL Code Generator Plugin](https://github.com/kobylynskyi/graphql-java-codegen-gradle-plugin) +to generate Java classes from the GraphQL schema. In this case, the schema is the Gropius schema +and the generated classes are used to interact with the Gropius API. -This should be named after the microservice itself. This is the root package for the microservice. It contains the `Application.java` file (or of similar name), which is the entry point for the microservice. Usually, this is the only class in this package. +The plugin is configured in the `build.gradle` file. The classes are generated on every build, so you don't need to run the generator manually. -### Config package -This package should contain any classes that are used to configure the application. This includes [Sprint Boot configuration classes](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/Configuration.html) but might also contain anything else related to configuration the microservice. -The classes that are in this package should not be deleted in the actual microservice as they provide useful functionality. +### Updating the Gropius Schema -### Controller package +To update the Gropius Schema, you can run the gradle task `refreshGropiusSchema`. The URL of the Gropius backend can be adjusted in the `build.gradle` file. -**Location:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/controller** +### Limitations of generated client classes -This package contains the GraphQL controllers (and other types of controllers if needed). The GraphQL controllers are annotated with the `@Controller` annotation. Controllers contain no business logic, but only delegate the requests to the service layer. They handle the "technical stuff" of the request. - -In some services, there is also a class called SubscriptionController which handles all dapr event subscriptions. - -More information can be found in -the [Controller package](src/main/java/de/unistuttgart/iste/meitrex/template/controller/package-info.java). - -### Dapr package - -**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/dapr - -This package should contain all classes that are used to communicate with Dapr, e.g. using pub sub. - -### DTO package - -**This package will not be located in the src/main/java folder, but in the build/generated folder.** - -This package contains the generated DTOs (data transfer objects) from the GraphQL schema. The DTOs are generated when building the project with gradle. - -If not necessary, no other files should be added manually to this package. - -#### Why both DTOs and Entities? - -The DTOs are used to transfer data between the GraphQL controller and the service layer. The entities are used to persist data in the database. This is done to separate the data transfer from the data persistence. This is a common approach in Spring Boot applications as it can happen that we want to store more data in the database than we want to transfer to the client or vice versa. - -### Exception package - -**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/exception - -This package is used for exception handling. Note that with GraphQL, the exceptions are not thrown directly, but are wrapped in a `GraphQLException`, which is different that from the usual Spring Boot approach. - -More information can be found in -the [Exception package](src/main/java/de/unistuttgart/iste/meitrex/template/exception/package-info.java). - -### Persistence package - -**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/persistence - -This package contains all classes that are used to persist data in the database. This includes the entities, the mapping -logic between entities and DTOs, as well as the repositories. - -This package handles the calls to the database and defines the database entities. It is structured into three sub-packages: - -#### 1. entity -This package contains the database entities. - -#### 2. repository -This package contains the interfaces to the database, also known as Data Access Objects (DAOs), used to perform various database operations. Note that these interfaces may sometimes be empty, especially when the default methods provided by the Spring Framework are sufficient for the required operations. - -#### 3. mapper -The 'mapper' package is responsible for the mapping logic between the database entities and the data types defined in the GraphQL schema. Specifically, it maps the database entity classes to the corresponding classes generated from the GraphQL schema. - -This structure helps organize the database-related components of the project, making it easier to manage and maintain. - -More information can be found in -the [Entity package](src/main/java/de/unistuttgart/iste/meitrex/template/persistence/entity/package-info.java) and -the [Repository package](src/main/java/de/unistuttgart/iste/meitrex/template/persistence/repository/package-info.java). - -### Service package - -**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/service - -This package contains all classes that are used to handle the business logic of the microservice. Services are annotated with the `@Service` annotation. Services contain only business logic and delegate the data access to the persistence layer (repositories). - -More information can be found in -the [Service package](src/main/java/de/unistuttgart/iste/meitrex/template/service/package-info.java). - -### Validation package - -**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/validation - -This package should contain the *class-level* validation logic, i.e. the validation logic that is not directly related to a specific field, e.g. validation if an end date is after a start date. - -Field-level validation logic should not be placed in this package, but in the graphql schema, via directives. -If these directives are not sufficient, the validation logic can also be placed in this package. - -## Getting Started - -### Todos - -Follow the guide in the wiki: https://github.com/MEITREX/wiki/blob/main/dev-manuals/backend/new-service.md - -Addtionally, after cloning the repository, you need to do the following steps: -- [ ] Setup the gradle files correctly. This means - - [ ] Change the project name in the `settings.gradle` file - - [ ] Change the package name in the `build.gradle` file (there is a TODO comment) - - [ ] Change the sonar project key in the `build.gradle` file (should be MEITREX_repository_name) - - [ ] Add/Remove dependencies in the `build.gradle` file -- [ ] Rename the package in the `src/main/java` folder to a more suitable name (should be the same as the package name in the `build.gradle` file) -- [ ] Remove the package-info.java files in the `src/main/java` folder (or update with the microservice specific information) -- [ ] Update the application.properties file in the `src/main/resources` folder (check the TODOS in the file) -- [ ] Change the ports and name of the database in the docker-compose.yml (see wiki on how to) -- [ ] Define the GraphQL schema in the `src/main/resources/schema.graphqls` file - - - -After creating a new service you need to do the following: -- [ ] Add the repository to sonarcloud, follow the instructions for extra configuration, unselect automatic analysis and choose github actions, only the first step needs to be completed -- [ ] Add SONAR_TOKEN to the service repository secrets on Github (this requires you to have admin permissions on sonarcloud) - -### Pull new changes from this template - -If this template changes and you want to pull the changes to the actual microservice, you can run the following commands: -```bash -git remote add template https://github.com/MEITREX/template_microservice # only necessary once -git fetch --all -git checkout [branch] # replace [branch] with the branch name you want the changes to be merged into (preferably not main) -git merge template/main --allow-unrelated-histories -# you will probably need to commit afterwars -``` - -### Guides -The following guides illustrate how to use some features concretely: - -* [Building a GraphQL service](https://spring.io/guides/gs/graphql-server/) -* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) -* [Validation with GraphQL directives](https://github.com/graphql-java/graphql-java-extended-validation/blob/master/README.md) -* [Error handling](https://www.baeldung.com/spring-graphql-error-handling) - -### Reference Documentation -For further reference, please consider the following sections: - -* [Official Gradle documentation](https://docs.gradle.org) -* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/3.0.6/gradle-plugin/reference/html/) -* [Spring Configuration Processor](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#appendix.configuration-metadata.annotation-processor) -* [Spring Boot DevTools](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#using.devtools) -* [Spring for GraphQL](https://docs.spring.io/spring-boot/docs/3.0.6/reference/html/web.html#web.graphql) -* [Spring Data JPA](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#data.sql.jpa-and-spring-data) -* [Validation](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#io.validation) -* [Generating Sonarqube Token](https://docs.sonarqube.org/latest/user-guide/user-account/generating-and-using-tokens/) -* [Adding secrets on Github](https://docs.github.com/en/actions/security-guides/encrypted-secrets) +The advantage of using the generated classes is that you can use them to interact with the Gropius API in a type-safe way. However, the generated classes are not perfect and there are some limitations: +- To few fields are generated if the field has parameters. In this case, add the type name to `fieldsWithoutResolvers` in the `build.gradle` file. +- There are serialization issues with `OffsetDateTime` and `LocalDateTime`. The workaround is to use `String` fields instead. This can be achieved using a `customTypeMapping` in the `build.gradle` file. +- Serialization of interfaces is not supported. If interfaces are queried, manually map them to a concrete subclass in the `customTypeMapping`.` \ No newline at end of file diff --git a/build.gradle b/build.gradle index fbeec69..6e118c1 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { id 'org.springframework.boot' version '3.+' id 'io.spring.dependency-management' version '1.+' id "io.github.kobylynskyi.graphql.codegen" version "5.+" - id "org.sonarqube" version "4.+" + id "org.sonarqube" version "5.+" id "jacoco" } @@ -22,39 +22,63 @@ if (jacocoEnabled.toBoolean()) { sonarqube { properties { - property("sonar.projectKey", "MEITREX_template-microservice") + property("sonar.projectKey", "MEITREX_dinodev_gropius_adapter") property("sonar.organization", "meitrex") property("sonar.host.url", "https://sonarcloud.io") } +} + +def gropiusUrl = System.properties.getProperty("gropiusUrl") ?: "http://localhost:8080" + +// run this task to refresh the Gropius schema +// it will download the schema from the Gropius server and save it to src/main/resources/graphql/gropius.graphqls +// this task is not run automatically, you have to run it manually (so you can control when the schema is updated) +// you can run it from the command line with `./gradlew refreshGropiusSchema` +tasks.register('refreshGropiusSchema', Exec) { + def filePath = 'src/main/resources/graphql/gropius.graphqls' + commandLine 'curl', "${gropiusUrl}/sdl", '-o', filePath } -// Automatically generate DTOs from GraphQL schema: +// generate gropius client graphqlCodegen { // all config options: // https://github.com/kobylynskyi/graphql-java-codegen/blob/main/docs/codegen-options.md - outputDir = new File("$buildDir/generated") - packageName = "de.unistuttgart.iste.meitrex.generated.dto" + graphqlSchemas { + rootDir = file("src/main/resources/graphql") + includePattern = "gropius.graphqls" + } + outputDir = new File("$buildDir/generated/gropius") + packageName = "de.unistuttgart.iste.gropius.generated.dto" generatedAnnotation = "jakarta.annotation.Generated" modelValidationAnnotation = "jakarta.validation.constraints.NotNull" generateApis = false // set to false as the generator does not support spring boot graphQL customTypesMapping = [ - "DateTime" : "java.time.OffsetDateTime", - "Date" : "java.time.LocalDate", - "Time" : "java.time.OffsetTime", - "LocalTime": "java.time.LocalTime", - "UUID" : "java.util.UUID", - "Url" : "java.net.URL", + "DateTime" : "java.time.OffsetDateTime", + "Date" : "java.time.LocalDate", + "Time" : "java.time.OffsetTime", + "LocalTime" : "java.time.LocalTime", + "UUID" : "java.util.UUID", + "Url" : "java.net.URL", + "JSON" : "java.lang.Object", + "DateTimeFilterInput.gt" : "String", // serialization of OffsetDateTime does not work + "Assignment.user" : "GropiusGropiusUser", // concrete sub type to allow serialization of the user object + "TimelineItemConnection.nodes": "de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.GropiusProjections.TimelineItemResponse", + "TrackableConnection.nodes" : "de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.GropiusProjections.TrackableResponse", ] + modelNamePrefix = "Gropius" generateEqualsAndHashCode = true generateToString = true + generateClient = true + // add fields that should be generated in the model classes + fieldsWithoutResolvers = ["Project", "Issue", "Component"] } // Automatically generate GraphQL code on project build: compileJava.dependsOn 'graphqlCodegen' // Add generated sources to your project source sets: -sourceSets.main.java.srcDir "$buildDir/generated" +sourceSets.main.java.srcDir "$buildDir/generated/gropius" configurations { compileOnly { @@ -67,20 +91,29 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.0.0' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.2' + implementation('de.unistuttgart.iste.meitrex:dinodev_common') { + version { + branch = 'main' + } + } + implementation 'de.unistuttgart.iste.meitrex:gamification_engine:1.2' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.modelmapper:modelmapper:3.+' implementation 'com.graphql-java:graphql-java-extended-scalars:20.0' implementation 'com.graphql-java:graphql-java-extended-validation:20.0' + implementation 'io.github.kobylynskyi:graphql-java-codegen:5.+' + implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.0.0' + testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.graphql:spring-graphql-test' @@ -93,5 +126,3 @@ dependencies { tasks.named('test') { useJUnitPlatform() } - -tasks.withType(Test).configureEach { testLogging.showStandardStreams = true } diff --git a/components/pubsub.yaml b/components/pubsub.yaml deleted file mode 100644 index 25b4fe0..0000000 --- a/components/pubsub.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: meitrex -spec: - type: pubsub.redis - version: v1 - metadata: - - name: redisHost - value: redis:6379 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 56174ae..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,60 +0,0 @@ -version: "3" - -services: - database-template: - image: postgres:alpine - restart: always - expose: - - 1032 - ports: - - "1032:5432" - volumes: - - templatedata:/var/lib/postgresql/data - environment: - - POSTGRES_USER=root - - POSTGRES_PASSWORD=root - - POSTGRES_DB=templatedatabase - app-template: - build: - context: ./../template-microservice # necessary because otherwise docker-compose overrides the context path when merging multiple docker-compose.yml files - dockerfile: Dockerfile - restart: always - container_name: template_microservice - expose: - - 1000 - - 1001 - ports: - - "1000:1000" - - "1001:1001" - depends_on: - - database-template - environment: - SPRING_DATASOURCE_URL: jdbc:postgresql://database-template:5432/templatedatabase - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: root - dapr-template: - image: "daprio/daprd" - command: [ - "./daprd", - "--app-id", "template_service", - "--app-port", "1001", - "--dapr-http-port", "1000", - "--resources-path", "./components" - ] - volumes: - - "./../template-microservice/components/:/components" # Mount our components folder for the runtime to use. The mounted location must match the --resources-path argument. - depends_on: - - app-template - - redis - network_mode: "service:app-template" - redis: - image: "redis:alpine" - expose: - - "6379" -volumes: - templatedata: - testdata: -networks: - default: - name: dapr-network - external: true \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 613ba1b..2a38bb2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,4 @@ -rootProject.name = 'template' +rootProject.name = 'gropius_adapter' sourceControl { gitRepository(uri('https://github.com/MEITREX/common')) { @@ -7,4 +7,10 @@ sourceControl { gitRepository(uri('https://github.com/MEITREX/common_test')) { producesModule('de.unistuttgart.iste.meitrex:meitrex-common-test') } + gitRepository(uri('https://github.com/MEITREX/dinodev_common')) { + producesModule('de.unistuttgart.iste.meitrex:dinodev_common') + } + gitRepository(uri('https://github.com/MEITREX/dinodev_gamification_engine')) { + producesModule('de.unistuttgart.iste.meitrex:gamification_engine') + } } \ No newline at end of file diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/GropiusAdapter.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/GropiusAdapter.java new file mode 100644 index 0000000..d5dcd79 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/GropiusAdapter.java @@ -0,0 +1,124 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius; + +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import de.unistuttgart.iste.meitrex.scrumgame.ims.ImsAdapter; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor.*; +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +public class GropiusAdapter implements ImsAdapter { + + private final GraphQlRequestExecutor graphQlRequestExecutor; + private final GropiusIssueMappingConfiguration mappingConfiguration; + + @Override + public List getIssues(UUID dinodevProjectId) { + return new GetIssuesRequestExecutor().executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public Optional findIssue(String id) { + return new FindIssueRequestExecutor(id).executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public List findIssuesBatched(List ids) { + return new FindIssuesBatchedRequestExecutor(ids).executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public Issue changeIssueTitle(String issueId, String title) { + return new ChangeIssueTitleRequestExecutor(issueId, title) + .executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public Issue changeIssueDescription(String issueId, String description) { + return new ChangeIssueDescriptionRequestExecutor(issueId, description) + .executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public Issue changeIssueState(String issueId, IssueState issueState) { + return new ChangeIssueStateRequestExecutor(issueId, issueState) + .executeRequest(graphQlRequestExecutor, mappingConfiguration) + .orElseGet(() -> findIssue(issueId).orElseThrow()); + } + + @Override + public Issue changeIssuePriority(String issueId, IssuePriority priority) { + return new ChangeIssuePriorityRequestExecutor(issueId, priority) + .executeRequest(graphQlRequestExecutor, mappingConfiguration) + .orElseGet(() -> findIssue(issueId).orElseThrow()); + } + + @Override + public Issue changeIssueType(String issueId, String typeName) { + return new ChangeIssueTypeRequestExecutor(issueId, typeName) + .executeRequest(graphQlRequestExecutor, mappingConfiguration) + .orElseGet(() -> findIssue(issueId).orElseThrow()); + } + + @Override + public Issue changeSprintOfIssue(String issueId, @Nullable Integer sprintNumber) { + return changeTemplateField(issueId, mappingConfiguration.getSprintFieldName(), sprintNumber); + } + + @Override + public Issue changeEstimationOfIssue(String issueId, TShirtSizeEstimation estimation) { + return changeTemplateField(issueId, + mappingConfiguration.getEstimationTemplateFieldName(), + estimation.toString()); + } + + private Issue changeTemplateField( + String issueId, + String fieldName, + Object value) { + + return new ChangeTemplateFieldRequestExecutor(issueId, fieldName, value) + .executeRequest(graphQlRequestExecutor, mappingConfiguration) + .orElseGet(() -> findIssue(issueId).orElseThrow()); + } + + @Override + public Issue assignIssue(String issueId, UUID assigneeId) { + return new AssignIssueRequestExecutor(issueId, assigneeId) + .executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public Issue addCommentToIssue(String issueId, String comment, @Nullable String optionalParentIssueId) { + return new AddCommentToIssueRequestExecutor(issueId, comment, optionalParentIssueId) + .executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public Issue createIssue(CreateIssueInput createIssueInput) { + return new CreateIssueRequestExecutor(createIssueInput) + .executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public List getEventsForIssue(String issueId, + OffsetDateTime since) { + return new GetEventsOfIssueRequestExecutor(issueId, since) + .executeRequest(graphQlRequestExecutor, mappingConfiguration); + } + + @Override + public List getEventsForProject(UUID projectId, OffsetDateTime since) { + return new GetEventsForProjectRequestExecutor(since) + .executeRequest(graphQlRequestExecutor, mappingConfiguration); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/GropiusIssueMappingConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/GropiusIssueMappingConfiguration.java new file mode 100644 index 0000000..69fc45a --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/GropiusIssueMappingConfiguration.java @@ -0,0 +1,64 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config; + +import java.util.UUID; + +/** + * Configuration for the issue mapping between DinoDev and the IMS. + */ +public interface GropiusIssueMappingConfiguration { + + /** + * Get the UUID of the DinoDev project. + * + * @return The DinoDev project ID. + */ + UUID getDinoDevProjectId(); + + /** + * Get the ID of the project in the IMS. + * + * @return The IMS project ID. + */ + String getImsProjectId(); + + /** + * Get the issue state converter for this configuration, used to convert issue states between DinoDev and the + * IMS. + * + * @return The issue state converter. + */ + IssueStateMapping getIssueStateConverter(); + + /** + * Get the issue priority mapping for this configuration, used to map issue priorities between DinoDev and + * the IMS. + * + * @return The issue priority mapping. + */ + IssuePriorityMapping getIssuePriorityMapping(); + + /** + * Get the issue type mapping for this configuration, used to map issue types between DinoDev and the IMS. + */ + IssueTypeMapping getIssueTypeMapping(); + + /** + * Get the name of the custom field in the IMS that contains the sprint information. + */ + String getSprintFieldName(); + + /** + * Get the name of the custom field in the IMS that contains the estimation information. + */ + String getEstimationTemplateFieldName(); + + /** + * Get the ID of the issue template in the IMS. + */ + String getIssueTemplateId(); + + /** + * Get the base URL of the Gropius instance. + */ + String getGropiusBaseUrl(); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssuePriorityMapping.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssuePriorityMapping.java new file mode 100644 index 0000000..f72d4d0 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssuePriorityMapping.java @@ -0,0 +1,28 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config; + +import de.unistuttgart.iste.meitrex.generated.dto.IssuePriority; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +/** + * Maps issue priorities between the DinoDev and the IMS. + */ +@RequiredArgsConstructor +public class IssuePriorityMapping { + + private final Map issuePriorityMap; + + + public IssuePriority getIssuePriority(String imsPriorityId) { + return issuePriorityMap.get(imsPriorityId); + } + + public String getIssuePriorityId(IssuePriority issuePriority) { + return issuePriorityMap.entrySet().stream() + .filter(entry -> entry.getValue() == issuePriority) + .map(Map.Entry::getKey) + .findFirst() + .orElse(issuePriority.toString()); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssueStateMapping.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssueStateMapping.java new file mode 100644 index 0000000..01a6368 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssueStateMapping.java @@ -0,0 +1,37 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config; + +import de.unistuttgart.iste.meitrex.generated.dto.IssueState; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Converts IMS issue states to IssueState objects. + */ +public class IssueStateMapping { + + private final Map issueStateMap; + + public IssueStateMapping(List issueStates) { + this.issueStateMap = issueStates.stream() + .collect(Collectors.toMap(IssueState::getImsStateId, Function.identity())); + } + + public IssueState getIssueState(String imsStateId) { + if (!issueStateMap.containsKey(imsStateId)) { + throw new IllegalArgumentException("Unknown IMS state ID: " + imsStateId); + } + return issueStateMap.get(imsStateId); + } + + public String getIssueStateId(String issueName) { + return issueStateMap.entrySet().stream() + .filter(entry -> entry.getValue().getName().equals(issueName)) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + } + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssueTypeMapping.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssueTypeMapping.java new file mode 100644 index 0000000..2571a8c --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/config/IssueTypeMapping.java @@ -0,0 +1,28 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Maps issue type names to their corresponding IMS issue type IDs. + */ +public class IssueTypeMapping { + + private final Map issueNameToImsIssueIdMap; + + public IssueTypeMapping(List issueTypes) { + issueNameToImsIssueIdMap = issueTypes.stream() + .collect(Collectors.toMap( + IssueTypeConfiguration::name, + IssueTypeConfiguration::imsTypeId)); + } + + public String getIssueTypeId(String issueTypeName) { + return issueNameToImsIssueIdMap.get(issueTypeName); + } + + public record IssueTypeConfiguration(String imsTypeId, String name) { + } + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AbstractGropiusRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AbstractGropiusRequestExecutor.java new file mode 100644 index 0000000..843199f --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AbstractGropiusRequestExecutor.java @@ -0,0 +1,23 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; + +import org.springframework.security.access.AccessDeniedException; + +public abstract class AbstractGropiusRequestExecutor { + + protected abstract T execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration); + + public T executeRequest(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + try { + return execute(graphQlRequestExecutor, mappingConfiguration); + } catch (Exception e) { + // workaround for "Invalid JWT" error + if (e.getMessage().toLowerCase().contains("invalid jwt")) { + throw new AccessDeniedException("Invalid JWT"); + } + throw e; + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AddCommentToIssueRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AddCommentToIssueRequestExecutor.java new file mode 100644 index 0000000..159a053 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AddCommentToIssueRequestExecutor.java @@ -0,0 +1,55 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class AddCommentToIssueRequestExecutor extends AbstractGropiusRequestExecutor{ + + private final String issueId; + private final String comment; + private final String optionalParentIssueId; + + @Override + protected Issue execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new CreateIssueCommentMutationRequest(); + request.setInput(GropiusCreateIssueCommentInput.builder() + .setIssue(issueId) + .setBody(comment) + .setAnswers(optionalParentIssueId) + .build()); + + var projection = new CreateIssueCommentPayloadResponseProjection() + .issueComment(new IssueCommentResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + try { + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusCreateIssueCommentPayload.class, projection) + .retrieve() + .map(response -> gropiusIssueToDinoDevIssue(response.getIssueComment().getIssue(), + mappingConfiguration)) + .block(); + } catch (Exception e) { + // workaround: It seems like sometimes the parent issue is not found, so we try again without it + request.setInput(GropiusCreateIssueCommentInput.builder() + .setIssue(issueId) + .setBody(comment) + .build()); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusCreateIssueCommentPayload.class, projection) + .retrieve() + .map(response -> gropiusIssueToDinoDevIssue(response.getIssueComment().getIssue(), + mappingConfiguration)) + .block(); + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AssignIssueRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AssignIssueRequestExecutor.java new file mode 100644 index 0000000..fedfeed --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/AssignIssueRequestExecutor.java @@ -0,0 +1,39 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; + +import java.util.UUID; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class AssignIssueRequestExecutor extends AbstractGropiusRequestExecutor { + + private final String issueId; + private final UUID assigneeId; + + @Override + protected Issue execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new CreateAssignmentMutationRequest(); + request.setInput(GropiusCreateAssignmentInput.builder() + .setIssue(issueId) + .setUser(assigneeId.toString()) + .build()); + + var projection = new CreateAssignmentPayloadResponseProjection() + .assignment(new AssignmentResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusCreateAssignmentPayload.class, projection) + .retrieve() + .map(response -> gropiusIssueToDinoDevIssue(response.getAssignment().getIssue(), + mappingConfiguration)) + .block(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueDescriptionRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueDescriptionRequestExecutor.java new file mode 100644 index 0000000..611756b --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueDescriptionRequestExecutor.java @@ -0,0 +1,33 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class ChangeIssueDescriptionRequestExecutor extends AbstractGropiusRequestExecutor { + + private final String issueId; + private final String description; + + @Override + protected Issue execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new UpdateBodyMutationRequest(); + request.setInput(new GropiusUpdateBodyInput(description, issueId)); + + var projection = new UpdateBodyPayloadResponseProjection() + .body(new BodyResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusUpdateBodyPayload.class, projection) + .retrieve() + .map(response -> gropiusIssueToDinoDevIssue(response.getBody().getIssue(), mappingConfiguration)) + .block(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssuePriorityRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssuePriorityRequestExecutor.java new file mode 100644 index 0000000..671b40f --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssuePriorityRequestExecutor.java @@ -0,0 +1,44 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.generated.dto.IssuePriority; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class ChangeIssuePriorityRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final String issueId; + private final IssuePriority priority; + + @Override + protected Optional execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new ChangeIssuePriorityMutationRequest(); + request.setInput(new GropiusChangeIssuePriorityInput(issueId, + mappingConfiguration.getIssuePriorityMapping().getIssuePriorityId(priority))); + + var projection = new ChangeIssuePriorityPayloadResponseProjection() + .priorityChangedEvent(new PriorityChangedEventResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusChangeIssuePriorityPayload.class, projection) + .retrieve() + .flatMap(response -> { + if (response.getPriorityChangedEvent() == null) { + return Mono.empty(); + } + return Mono.just(gropiusIssueToDinoDevIssue(response.getPriorityChangedEvent().getIssue(), + mappingConfiguration)); + }) + .blockOptional(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueStateRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueStateRequestExecutor.java new file mode 100644 index 0000000..9a54413 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueStateRequestExecutor.java @@ -0,0 +1,41 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.generated.dto.IssueState; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class ChangeIssueStateRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final String issueId; + private final IssueState issueState; + + @Override + protected Optional execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new ChangeIssueStateMutationRequest(); + request.setInput(new GropiusChangeIssueStateInput(issueId, issueState.getImsStateId())); + + var projection = new ChangeIssueStatePayloadResponseProjection() + .stateChangedEvent(new StateChangedEventResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusChangeIssueStatePayload.class, projection) + .retrieve() + .flatMap(response -> + response.getStateChangedEvent() == null + ? Mono.empty() + : Mono.just(gropiusIssueToDinoDevIssue( + response.getStateChangedEvent().getIssue(), mappingConfiguration))) + .blockOptional(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueTitleRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueTitleRequestExecutor.java new file mode 100644 index 0000000..0644400 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueTitleRequestExecutor.java @@ -0,0 +1,38 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class ChangeIssueTitleRequestExecutor extends AbstractGropiusRequestExecutor { + + private final String issueId; + private final String newTitle; + + @Override + protected Issue execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = ChangeIssueTitleMutationRequest.builder() + .setInput(GropiusChangeIssueTitleInput.builder() + .setIssue(issueId) + .setTitle(newTitle) + .build()) + .build(); + + var projection = new ChangeIssueTitlePayloadResponseProjection() + .titleChangedEvent(new TitleChangedEventResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusChangeIssueTitlePayload.class, projection) + .retrieve() + .map(response -> gropiusIssueToDinoDevIssue(response.getTitleChangedEvent().getIssue(), + mappingConfiguration)) + .block(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueTypeRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueTypeRequestExecutor.java new file mode 100644 index 0000000..87e4573 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeIssueTypeRequestExecutor.java @@ -0,0 +1,38 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class ChangeIssueTypeRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final String issueId; + private final String typeName; + + @Override + protected Optional execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new ChangeIssueTypeMutationRequest(); + request.setInput(new GropiusChangeIssueTypeInput(issueId, typeName)); + + var projection = new ChangeIssueTypePayloadResponseProjection() + .typeChangedEvent(new TypeChangedEventResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusChangeIssueTypePayload.class, projection) + .retrieve() + .flatMap(response -> response.getTypeChangedEvent() == null + ? Mono.empty() + : Mono.just(gropiusIssueToDinoDevIssue(response.getTypeChangedEvent().getIssue(), mappingConfiguration))) + .blockOptional(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeTemplateFieldRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeTemplateFieldRequestExecutor.java new file mode 100644 index 0000000..c26a751 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/ChangeTemplateFieldRequestExecutor.java @@ -0,0 +1,44 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +import java.util.Optional; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class ChangeTemplateFieldRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final String issueId; + private final String templateFieldName; + private final Object newTemplateFieldValue; + + @Override + protected Optional execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new ChangeIssueTemplatedFieldMutationRequest(); + request.setInput(GropiusChangeIssueTemplatedFieldInput.builder() + .setIssue(issueId) + .setName(templateFieldName) + .setValue(newTemplateFieldValue) + .build()); + + var projection = new ChangeIssueTemplatedFieldPayloadResponseProjection() + .templatedFieldChangedEvent(new TemplatedFieldChangedEventResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection())); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusChangeIssueTemplatedFieldPayload.class, projection) + .retrieve() + .flatMap(response -> response.getTemplatedFieldChangedEvent() == null + ? Mono.empty() + : Mono.just(gropiusIssueToDinoDevIssue( + response.getTemplatedFieldChangedEvent().getIssue(), mappingConfiguration))) + .blockOptional(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/CreateIssueRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/CreateIssueRequestExecutor.java new file mode 100644 index 0000000..0231ace --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/CreateIssueRequestExecutor.java @@ -0,0 +1,47 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.CreateIssueInput; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class CreateIssueRequestExecutor extends AbstractGropiusRequestExecutor { + + private final CreateIssueInput createIssueInput; + + @Override + protected Issue execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = new CreateIssueMutationRequest(); + request.setInput(GropiusCreateIssueInput.builder() + .setTrackables(List.of(mappingConfiguration.getImsProjectId())) + .setTitle(createIssueInput.getTitle()) + .setBody(createIssueInput.getDescription()) + .setState(mappingConfiguration.getIssueStateConverter() + .getIssueStateId(createIssueInput.getStateName())) + .setType(mappingConfiguration.getIssueTypeMapping().getIssueTypeId(createIssueInput.getTypeName())) + .setTemplate(mappingConfiguration.getIssueTemplateId()) + .setTemplatedFields(List.of( + GropiusJSONFieldInput.builder() + .setName(mappingConfiguration.getSprintFieldName()) + .setValue(createIssueInput.getSprintNumber()) + .build())) + .build()); + + var projection = new CreateIssuePayloadResponseProjection() + .issue(GropiusProjections.getDefaultIssueProjection()); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusCreateIssuePayload.class, projection) + .retrieve() + .map(response -> gropiusIssueToDinoDevIssue(response.getIssue(), mappingConfiguration)) + .block(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/FindIssueRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/FindIssueRequestExecutor.java new file mode 100644 index 0000000..ca333e1 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/FindIssueRequestExecutor.java @@ -0,0 +1,27 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.GropiusIssue; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping; +import lombok.RequiredArgsConstructor; + +import java.util.Optional; + +@RequiredArgsConstructor +public class FindIssueRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final String issueId; + + @Override + protected Optional execute(GraphQlRequestExecutor graphQlRequestExecutor, + GropiusIssueMappingConfiguration mappingConfiguration) { + return graphQlRequestExecutor + .request(GropiusRequests.getIssueQueryRequest(issueId)) + .projectTo(GropiusIssue.class, GropiusProjections.getDefaultIssueProjection()) + .retrieveList() + .map(issues -> GropiusMapping.gropiusIssueToDinoDevIssue(issues.getFirst(), mappingConfiguration)) + .blockOptional(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/FindIssuesBatchedRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/FindIssuesBatchedRequestExecutor.java new file mode 100644 index 0000000..4896375 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/FindIssuesBatchedRequestExecutor.java @@ -0,0 +1,45 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.GropiusIDFilterInput; +import de.unistuttgart.iste.gropius.generated.dto.GropiusIssue; +import de.unistuttgart.iste.gropius.generated.dto.GropiusIssueFilterInput; +import de.unistuttgart.iste.gropius.generated.dto.SearchIssuesQueryRequest; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.common.util.MeitrexCollectionUtils; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RequiredArgsConstructor +public class FindIssuesBatchedRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final List issueIds; + + @Override + protected List execute(GraphQlRequestExecutor graphQlRequestExecutor, + GropiusIssueMappingConfiguration mappingConfiguration) { + var request = SearchIssuesQueryRequest.builder() + .setQuery("*") // query must be given + .setFirst(issueIds.size()) + .setFilter(GropiusIssueFilterInput.builder() + .setId(GropiusIDFilterInput.builder().setIn(issueIds).build()) + .build()) + .build(); + + List issues = graphQlRequestExecutor + .request(request) + .projectTo(GropiusIssue.class, GropiusProjections.getDefaultIssueProjection()) + .retrieveList() + .map(gropiusIssues -> gropiusIssues.stream() + .map(gropiusIssue -> GropiusMapping.gropiusIssueToDinoDevIssue(gropiusIssue, + mappingConfiguration)) + .toList()) + .blockOptional() + .orElseThrow(); + + return MeitrexCollectionUtils.sortByKeys(issues, issueIds, Issue::getId); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetEventsForProjectRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetEventsForProjectRequestExecutor.java new file mode 100644 index 0000000..0f856a2 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetEventsForProjectRequestExecutor.java @@ -0,0 +1,53 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.CreateEventInput; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping; +import lombok.RequiredArgsConstructor; + +import java.time.OffsetDateTime; +import java.util.List; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.getIssuesFromProjectAndComponents; + +@RequiredArgsConstructor +public class GetEventsForProjectRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final OffsetDateTime since; + + @Override + protected List execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var request = GropiusRequests.getProjectRequest(mappingConfiguration.getImsProjectId()); + + return graphQlRequestExecutor + .request(request) + .projectTo(GropiusProjectConnection.class, getProjection()) + .retrieve() + .map(response -> getIssuesFromProjectAndComponents(response) + .flatMap(gropiusIssue -> GropiusMapping.getTimeLineItemsOfIssue(mappingConfiguration, gropiusIssue).stream()) + .toList()) + .block(); + } + + private ProjectConnectionResponseProjection getProjection() { + return new ProjectConnectionResponseProjection() + .nodes(new ProjectResponseProjection() + .issues(new ProjectIssuesParametrizedInput() + .filter(GropiusIssueFilterInput.builder() + .setLastModifiedAt(GropiusDateTimeFilterInput.builder() + .setGt(since.toString()) + .build()) + .build()), + new IssueConnectionResponseProjection() + .nodes(GropiusProjections.getIssueWithTimelineItemsProjection(since))) + .components(new ComponentVersionConnectionResponseProjection() + .nodes(new ComponentVersionResponseProjection() + .component(new ComponentResponseProjection() + .issues(new IssueConnectionResponseProjection() + .nodes(GropiusProjections.getIssueWithTimelineItemsProjection(since))))))); + } + + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetEventsOfIssueRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetEventsOfIssueRequestExecutor.java new file mode 100644 index 0000000..7acaf6d --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetEventsOfIssueRequestExecutor.java @@ -0,0 +1,41 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.GropiusIssue; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.CreateEventInput; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping; +import lombok.RequiredArgsConstructor; + +import java.time.OffsetDateTime; +import java.util.List; + +@RequiredArgsConstructor +public class GetEventsOfIssueRequestExecutor extends AbstractGropiusRequestExecutor> { + + private final String issueId; + private final OffsetDateTime since; + + @Override + protected List execute(GraphQlRequestExecutor graphQlRequestExecutor, GropiusIssueMappingConfiguration mappingConfiguration) { + var projection = GropiusProjections.getIssueWithTimelineItemsProjection(since); + + return graphQlRequestExecutor + .request(GropiusRequests.getIssueQueryRequest(issueId)) + .projectTo(GropiusIssue.class, projection) + .retrieveList() + .map(issues -> getTimeLineItemsOfFirstIssue(mappingConfiguration, issues)) + .defaultIfEmpty(List.of()) + .block(); + } + + static List getTimeLineItemsOfFirstIssue(GropiusIssueMappingConfiguration mappingConfiguration, + List issues) { + if (issues.isEmpty()) { + return List.of(); + } + + var issue = issues.getFirst(); + return GropiusMapping.getTimeLineItemsOfIssue(mappingConfiguration, issue); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetIssuesRequestExecutor.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetIssuesRequestExecutor.java new file mode 100644 index 0000000..006c656 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GetIssuesRequestExecutor.java @@ -0,0 +1,38 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.GropiusProjectConnection; +import de.unistuttgart.iste.meitrex.common.graphqlclient.GraphQlRequestExecutor; +import de.unistuttgart.iste.meitrex.generated.dto.Issue; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Objects; + +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.getIssuesFromProjectAndComponents; +import static de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping.GropiusMapping.gropiusIssueToDinoDevIssue; + +@RequiredArgsConstructor +public class GetIssuesRequestExecutor extends AbstractGropiusRequestExecutor> { + + @Override + protected List execute(GraphQlRequestExecutor graphQlRequestExecutor, + GropiusIssueMappingConfiguration mappingConfiguration) { + return graphQlRequestExecutor + .request(GropiusRequests.getProjectRequest(mappingConfiguration.getImsProjectId())) + .projectTo(GropiusProjectConnection.class, GropiusProjections.getProjectConnectionProjection()) + .retrieve() + .map(response -> getAllIssuesFromProjectConnection(response, mappingConfiguration)) + .defaultIfEmpty(List.of()) + .block(); + } + + private List getAllIssuesFromProjectConnection(GropiusProjectConnection response, + GropiusIssueMappingConfiguration mappingConfiguration) { + return getIssuesFromProjectAndComponents(response) + .map(gropiusIssue -> gropiusIssueToDinoDevIssue(gropiusIssue, mappingConfiguration)) + .filter(Objects::nonNull) + .toList(); + } + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GropiusProjections.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GropiusProjections.java new file mode 100644 index 0000000..ef4cc3d --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GropiusProjections.java @@ -0,0 +1,158 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Some commonly used projections for Gropius responses. + */ +@SuppressWarnings("OverlyCoupledClass") +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class GropiusProjections { + + private static final IssueResponseProjection DEFAULT_ISSUE_RESPONSE_PROJECTION = new IssueResponseProjection() + .id() + .trackables(new TrackableConnectionResponseProjection() + .nodes(new TrackableResponseProjection().id().typename())) + .title() + .template(new IssueTemplateResponseProjection().id()) + .body(new BodyResponseProjection().body()) + .state(new IssueStateResponseProjection().id()) + .priority(new IssuePriorityResponseProjection().id().value()) + .type(new IssueTypeResponseProjection() + .id() + .name() + .description() + .iconPath()) + .assignments(new AssignmentConnectionResponseProjection() + .nodes(new AssignmentResponseProjection() + .id() + .user(new UserResponseProjection().id().username()))) + .labels(new LabelConnectionResponseProjection() + .nodes(new LabelResponseProjection().id().name())) + .templatedFields(new JSONFieldResponseProjection().all$()); + + public static final TimelineItemConnectionResponseProjection TIMELINE_ITEM_CONNECTION_RESPONSE_PROJECTION + = new TimelineItemConnectionResponseProjection() + .nodes(new TimelineItemResponseProjection() + .id() + .typename() + .createdAt() + .lastModifiedAt() + .lastModifiedBy(new UserResponseProjection().id()) + .onAddedLabelEvent(new AddedLabelEventResponseProjection() + .addedLabel(new LabelResponseProjection().id().name())) + .onAssignment(new AssignmentResponseProjection() + .user(new UserResponseProjection().username().id())) + .onIssueComment(new IssueCommentResponseProjection() + .body() + .answers(new CommentResponseProjection().id())) + .onPriorityChangedEvent(new PriorityChangedEventResponseProjection() + .newPriority(new IssuePriorityResponseProjection().id().value()) + .oldPriority(new IssuePriorityResponseProjection().id().value())) + .onRemovedAssignmentEvent(new RemovedAssignmentEventResponseProjection() + .removedAssignment(new AssignmentResponseProjection().user(new UserResponseProjection().username() + .id()))) + .onStateChangedEvent(new StateChangedEventResponseProjection() + .newState(new IssueStateResponseProjection().id()) + .oldState(new IssueStateResponseProjection().id())) + .onTemplatedFieldChangedEvent(new TemplatedFieldChangedEventResponseProjection() + .newValue() + .oldValue() + .fieldName()) + .onRemovedTemplatedFieldEvent(new RemovedTemplatedFieldEventResponseProjection() + .fieldName()) + .onRemovedLabelEvent(new RemovedLabelEventResponseProjection() + .removedLabel(new LabelResponseProjection().id().name())) + .onTitleChangedEvent(new TitleChangedEventResponseProjection() + .newTitle() + .oldTitle()) + .onTypeChangedEvent(new TypeChangedEventResponseProjection() + .newType(new IssueTypeResponseProjection().id().name()) + .oldType(new IssueTypeResponseProjection().id().name()) + )); + + public static IssueResponseProjection getIssueWithTimelineItemsProjection(OffsetDateTime since) { + return new IssueResponseProjection() + .id() + .title() + .assignments(new AssignmentConnectionResponseProjection() + .nodes(new AssignmentResponseProjection() + .id() + .user(new UserResponseProjection().id().username()))) + .timelineItems(new IssueTimelineItemsParametrizedInput() + .filter(GropiusTimelineItemFilterInput.builder() + .setLastModifiedAt(GropiusDateTimeFilterInput.builder() + .setGt(since.toString()) + .build()) + .build()) + .orderBy(List.of(GropiusTimelineItemOrder.builder() + .setField(GropiusTimelineItemOrderField.CREATED_AT) + .setDirection(GropiusOrderDirection.DESC) + .build())) + .first(1000), + TIMELINE_ITEM_CONNECTION_RESPONSE_PROJECTION); + } + + // record to serialize the response of the timeline query + @SuppressWarnings("ClassWithTooManyFields") + public record TimelineItemResponse( + UUID id, + String __typename, + OffsetDateTime createdAt, + OffsetDateTime lastModifiedAt, + GropiusGropiusUser lastModifiedBy, + GropiusLabel addedLabel, + GropiusGropiusUser user, + String body, + GropiusIssueComment answers, + GropiusIssuePriority newPriority, + GropiusIssuePriority oldPriority, + GropiusAssignment removedAssignment, + GropiusIssueState newState, + GropiusIssueState oldState, + String newValue, + String oldValue, + String fieldName, + GropiusLabel removedLabel, + String newTitle, + String oldTitle, + GropiusIssueType newType, + GropiusIssueType oldType + ) { + + public String typename() { + return __typename; + } + } + + public record TrackableResponse(UUID id, String __typename) { + + public String typename() { + return __typename; + } + } + + public static ProjectConnectionResponseProjection getProjectConnectionProjection() { + return new ProjectConnectionResponseProjection() + .nodes(new ProjectResponseProjection() + .issues(getDefaultIssueConnectionProjection()) + .components(new ComponentVersionConnectionResponseProjection() + .nodes(new ComponentVersionResponseProjection() + .component(new ComponentResponseProjection() + .issues(getDefaultIssueConnectionProjection()))))); + } + + public static IssueConnectionResponseProjection getDefaultIssueConnectionProjection() { + return new IssueConnectionResponseProjection().nodes(getDefaultIssueProjection()); + } + + public static IssueResponseProjection getDefaultIssueProjection() { + return DEFAULT_ISSUE_RESPONSE_PROJECTION; + } + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GropiusRequests.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GropiusRequests.java new file mode 100644 index 0000000..ded79b9 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/executor/GropiusRequests.java @@ -0,0 +1,35 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Commonly used Gropius requests. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GropiusRequests { + + public static ProjectsQueryRequest getProjectRequest(String imsProjectId) { + return ProjectsQueryRequest.builder() + .setFilter(GropiusProjectFilterInput.builder() + .setId(idFilter(imsProjectId)) + .build()) + .build(); + } + + public static SearchIssuesQueryRequest getIssueQueryRequest(String issueId) { + return SearchIssuesQueryRequest.builder() + .setQuery("*") // query must be given + .setFirst(1) + .setFilter(GropiusIssueFilterInput.builder() + .setId(idFilter(issueId)) + .build()) + .build(); + } + + static GropiusIDFilterInput idFilter(String id) { + return GropiusIDFilterInput.builder().setEq(id).build(); + } + +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/mapping/GropiusMapping.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/mapping/GropiusMapping.java new file mode 100644 index 0000000..a587dee --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/mapping/GropiusMapping.java @@ -0,0 +1,148 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping; + +import de.unistuttgart.iste.gropius.generated.dto.*; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor.GropiusProjections.TrackableResponse; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import de.unistuttgart.iste.meitrex.scrumgame.util.TShirtSizeEstimationStoryPointsConverter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import static de.unistuttgart.iste.meitrex.common.util.MeitrexCollectionUtils.distinctByKey; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class GropiusMapping { + + public static Issue gropiusIssueToDinoDevIssue( + GropiusIssue issueResponse, + GropiusIssueMappingConfiguration mappingConfiguration) { + + if (issueResponse == null || issueResponse.getTemplate() == null + || !issueResponse.getTemplate().getId().equals(mappingConfiguration.getIssueTemplateId())) { + return null; // TODO make this method return optional + } + + Optional estimationField = findTemplateField( + issueResponse.getTemplatedFields(), + mappingConfiguration.getEstimationTemplateFieldName()); + Optional estimation = estimationField + .map(Object::toString) + .flatMap(estimateString -> parseEnum(estimateString, TShirtSizeEstimation.class)); + Optional storyPointsValue = estimation + .map(TShirtSizeEstimationStoryPointsConverter::getStoryPoints); + + return Issue.builder() + .setId(issueResponse.getId()) + .setProjectId(mappingConfiguration.getDinoDevProjectId()) + .setTitle(issueResponse.getTitle()) + .setDescription(issueResponse.getBody().getBody()) + .setState(mappingConfiguration.getIssueStateConverter() + .getIssueState(issueResponse.getState().getId())) + .setType(IssueType.builder() + .setName(issueResponse.getType().getName()) + .setDescription(issueResponse.getType().getDescription()) + .setIconPath(issueResponse.getType().getIconPath()) + .build()) + .setPriority(getIssuePriority(issueResponse.getPriority(), mappingConfiguration)) + .setAssigneeIds( + issueResponse.getAssignments().getNodes().stream() + .map(assignment -> assignment.getUser().getId()) + .map(UUID::fromString) + .toList()) + .setSprintNumber( + findTemplateField(issueResponse.getTemplatedFields(), mappingConfiguration.getSprintFieldName()) + .flatMap(GropiusMapping::parseIntSafely) + .orElse(null)) + .setStoryPoints(storyPointsValue.orElse(null)) + .setEffortEstimation(estimation.orElse(null)) + .setLabels(issueResponse.getLabels().getNodes().stream() + .map(GropiusLabel::getName) + .toList()) + .setIssueUrl(getIssueUrl(issueResponse, mappingConfiguration)) + .build(); + } + + public static Stream getIssuesFromProjectAndComponents(GropiusProjectConnection response) { + Stream issuesFromProject = response.getNodes().stream() + .flatMap(project -> project.getIssues().getNodes().stream()); + Stream issuesFromComponents = response.getNodes().stream() + .flatMap(project -> project.getComponents().getNodes().stream()) + .flatMap(component -> component.getComponent().getIssues().getNodes().stream()); + + return Stream.concat(issuesFromProject, issuesFromComponents) + .filter(distinctByKey(GropiusIssue::getId)); // remove duplicates + } + + private static String getIssueUrl(GropiusIssue issueResponse, + GropiusIssueMappingConfiguration mappingConfiguration) { + List trackables = issueResponse.getTrackables().getNodes(); + Optional firstTrackable = trackables.stream().findFirst(); + + String issueUrl = mappingConfiguration.getGropiusBaseUrl(); + if (firstTrackable.isPresent()) { + if ("Project".equals(firstTrackable.get().typename())) { + issueUrl += "/projects/" + firstTrackable.get().id(); + } else if ("Component".equals(firstTrackable.get().typename())) { + issueUrl += "/components/" + firstTrackable.get().id(); + } + } else { + issueUrl += "/projects/" + mappingConfiguration.getImsProjectId(); + } + issueUrl += "/issues/" + issueResponse.getId(); + return issueUrl; + } + + private static IssuePriority getIssuePriority(GropiusIssuePriority priority, + GropiusIssueMappingConfiguration mappingConfiguration) { + IssuePriority issuePriority = null; + if (priority != null) { + issuePriority = mappingConfiguration.getIssuePriorityMapping().getIssuePriority(priority.getId()); + } + return issuePriority != null ? issuePriority : IssuePriority.MEDIUM; + } + + private static Optional findTemplateField(List templatedFields, String fieldName) { + return templatedFields.stream() + .filter(field -> field.getName().equals(fieldName)) + .map(GropiusJSONField::getValue) + .filter(Objects::nonNull) + .findFirst(); + } + + private static > Optional parseEnum(String value, Class enumType) { + try { + return Optional.of(Enum.valueOf(enumType, value)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private static Optional parseIntSafely(Object value) { + if (value instanceof Integer intValue) { + return Optional.of(intValue); + } + + if (value instanceof String stringValue) { + try { + return Optional.of(Integer.parseInt(stringValue)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + return Optional.empty(); + } + + public static List getTimeLineItemsOfIssue(GropiusIssueMappingConfiguration mappingConfiguration, + GropiusIssue issue) { + return issue.getTimelineItems().getNodes().stream() + .flatMap(timelineItem -> GropiusTimelineItemToEventConverter + .convertTimelineItemToEvents(issue, timelineItem, mappingConfiguration).stream()) + .toList(); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/mapping/GropiusTimelineItemToEventConverter.java b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/mapping/GropiusTimelineItemToEventConverter.java new file mode 100644 index 0000000..fd807e9 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/scrumgame/ims/gropius/mapping/GropiusTimelineItemToEventConverter.java @@ -0,0 +1,199 @@ +package de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.mapping; + +import de.unistuttgart.iste.gropius.generated.dto.GropiusIssue; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import de.unistuttgart.iste.meitrex.scrumgame.ims.ImsEventTypes; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.executor.GropiusProjections.TimelineItemResponse; +import de.unistuttgart.iste.meitrex.scrumgame.ims.gropius.config.GropiusIssueMappingConfiguration; +import de.unistuttgart.iste.meitrex.scrumgame.util.StateUtils; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Converts a Gropius timeline item to a DinoDev event. + */ +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class GropiusTimelineItemToEventConverter { + + public static List convertTimelineItemToEvents( + GropiusIssue issue, + TimelineItemResponse timelineItem, + GropiusIssueMappingConfiguration configuration + ) { + EventType eventType = getEventType(timelineItem, configuration); + + CreateEventInput baseEvent = CreateEventInput.builder() + .setId(timelineItem.id()) + .setTimestamp(timelineItem.createdAt()) + .setUserId(UUID.fromString(timelineItem.lastModifiedBy().getId())) + .setProjectId(configuration.getDinoDevProjectId()) + .setEventTypeIdentifier(eventType.getIdentifier()) + .build(); + + if (baseEvent.getEventTypeIdentifier().equals(ImsEventTypes.ISSUE_UPDATED.getIdentifier())) { + baseEvent.setMessage(getMessage(timelineItem, configuration)); + } + if (baseEvent.getEventTypeIdentifier().equals(ImsEventTypes.COMMENT_ON_ISSUE.getIdentifier())) { + baseEvent.setMessage(timelineItem.body()); + } + + if (timelineItem.answers() != null) { + baseEvent.setParentId(UUID.fromString(timelineItem.answers().getId())); + } + + baseEvent.setEventData(getEventData(issue, timelineItem, eventType)); + + if (baseEvent.getEventTypeIdentifier().equals(ImsEventTypes.ISSUE_COMPLETED.getIdentifier()) + && !issue.getAssignments().getNodes().isEmpty()) { + return issue.getAssignments().getNodes().stream() + .map(assignment -> CreateEventInput.builder() + .setEventData(baseEvent.getEventData()) + .setEventTypeIdentifier(baseEvent.getEventTypeIdentifier()) + // unique ID for each event, also prevents opening and closing the same issue multiple times + .setId(UUID.nameUUIDFromBytes((issue.getId() + + assignment.getUser().getId()).getBytes())) + .setMessage(baseEvent.getMessage()) + .setParentId(baseEvent.getParentId()) + .setProjectId(baseEvent.getProjectId()) + .setTimestamp(baseEvent.getTimestamp()) + .setUserId(UUID.fromString(assignment.getUser().getId())) + .build()) + .toList(); + } + + + return List.of(baseEvent); + } + + private static EventType getEventType( + TimelineItemResponse timelineItem, + GropiusIssueMappingConfiguration configuration + ) { + return switch (timelineItem.typename()) { + case "IssueComment", "Comment" -> ImsEventTypes.COMMENT_ON_ISSUE; + case "Body" -> ImsEventTypes.ISSUE_CREATED; // Body is the first timeline item of an issue + case "Assignment" -> ImsEventTypes.ASSIGNED_ISSUE; + case "RemovedAssignmentEvent" -> ImsEventTypes.UNASSIGNED_ISSUE; + case "StateChangedEvent" -> getStateChangedEventType(timelineItem, configuration); + default -> ImsEventTypes.ISSUE_UPDATED; // more specific event types can be implemented as needed + }; + } + + private static EventType getStateChangedEventType(TimelineItemResponse timelineItem, + GropiusIssueMappingConfiguration configuration) { + IssueState oldState = configuration.getIssueStateConverter().getIssueState(timelineItem.oldState().getId()); + IssueState newState = configuration.getIssueStateConverter().getIssueState(timelineItem.newState().getId()); + + if (StateUtils.isMovedToDone(oldState, newState)) { + return ImsEventTypes.ISSUE_COMPLETED; + } + if (StateUtils.isMovedOutOfSprint(oldState, newState)) { + return ImsEventTypes.REMOVE_ISSUE_FROM_SPRINT; + } + if (StateUtils.isMovedIntoSprint(oldState, newState)) { + return ImsEventTypes.ADD_ISSUE_TO_SPRINT; + } + if (StateUtils.isReopened(oldState, newState)) { + return ImsEventTypes.ISSUE_REOPENED; + } + if (StateUtils.isMovedToInProgress(oldState, newState)) { + return ImsEventTypes.START_PROGRESS; + } + + return ImsEventTypes.ISSUE_UPDATED; + } + + private static String getMessage( + TimelineItemResponse timelineItemResponse, + GropiusIssueMappingConfiguration configuration + ) { + return switch (timelineItemResponse.typename()) { + case "AddedLabelEvent" -> "added the label " + timelineItemResponse.addedLabel().getName() + "."; + case "PriorityChangedEvent" -> getPriorityChangedMessage(timelineItemResponse); + case "TemplatedFieldChangedEvent" -> "changed the field " + timelineItemResponse.fieldName() + " from " + + timelineItemResponse.oldValue() + " to " + + timelineItemResponse.newValue() + "."; + case "RemovedTemplatedFieldEvent" -> "removed the field " + timelineItemResponse.fieldName() + "."; + case "TitleChangedEvent" -> "changed the title from " + timelineItemResponse.oldTitle() + " to " + + timelineItemResponse.newTitle() + "."; + case "TypeChangedEvent" -> getTypeChangedMessage(timelineItemResponse); + case "RemovedLabelEvent" -> "removed the label " + timelineItemResponse.removedLabel().getName() + "."; + case "StateChangedEvent" -> getStateChangedMessage(timelineItemResponse, configuration); + + default -> "updated the issue, see details in Gropius."; // add more cases as needed + }; + } + + private static String getStateChangedMessage( + TimelineItemResponse timelineItemResponse, + GropiusIssueMappingConfiguration configuration + ) { + IssueState oldState = configuration.getIssueStateConverter() + .getIssueState(timelineItemResponse.oldState().getId()); + IssueState newState = configuration.getIssueStateConverter() + .getIssueState(timelineItemResponse.newState().getId()); + + return "changed the state from " + oldState.getName() + " to " + newState.getName() + "."; + } + + private static String getPriorityChangedMessage(TimelineItemResponse timelineItemResponse) { + if (timelineItemResponse.oldPriority() != null && timelineItemResponse.newPriority() != null) { + return "changed the priority from " + timelineItemResponse.oldPriority().getValue() + + " to " + timelineItemResponse.newPriority().getValue() + "."; + } + if (timelineItemResponse.newPriority() != null) { + return "changed the priority to " + timelineItemResponse.newPriority().getValue() + "."; + } + + return "changed the priority."; + } + + private static String getTypeChangedMessage(TimelineItemResponse timelineItemResponse) { + if (timelineItemResponse.oldType() != null && timelineItemResponse.newType() != null) { + return "changed the type from " + timelineItemResponse.oldType().getName() + " to " + + timelineItemResponse.newType().getName() + "."; + } + if (timelineItemResponse.newType() != null) { + return "changed the type to " + timelineItemResponse.newType().getName() + "."; + } + return "changed the issue type."; + } + + private static List getEventData( + GropiusIssue issue, + TimelineItemResponse timelineItem, + EventType eventType + ) { + var eventData = new ArrayList(); + + eventData.add(new DataFieldInput("issueId", AllowedDataType.STRING, issue.getId())); + eventData.add(new DataFieldInput("issueTitle", AllowedDataType.STRING, issue.getTitle())); + eventData.add(new DataFieldInput("assigneeIds", AllowedDataType.STRING, + issue.getAssignments().getNodes().stream() + .map(assignment -> assignment.getUser().getId()) + .collect(Collectors.joining(",")))); + eventData.add(new DataFieldInput("assigneeNames", AllowedDataType.STRING, + issue.getAssignments().getNodes().stream() + .map(assignment -> assignment.getUser().getUsername()) + .collect(Collectors.joining(",")))); + + if (eventType == ImsEventTypes.COMMENT_ON_ISSUE) { + eventData.add(new DataFieldInput("comment", AllowedDataType.STRING, timelineItem.body())); + } + if (eventType == ImsEventTypes.ASSIGNED_ISSUE || eventType == ImsEventTypes.UNASSIGNED_ISSUE) { + if (timelineItem.user() != null) { + var userId = UUID.fromString(timelineItem.user().getId()); + eventData.add(new DataFieldInput("assigneeId", AllowedDataType.STRING, userId.toString())); + eventData.add(new DataFieldInput("assigneeName", + AllowedDataType.STRING, + timelineItem.user().getUsername())); + } + } + + return eventData; + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/template/TemplateForMicroservicesApplication.java b/src/main/java/de/unistuttgart/iste/meitrex/template/TemplateForMicroservicesApplication.java deleted file mode 100644 index d12a944..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/template/TemplateForMicroservicesApplication.java +++ /dev/null @@ -1,23 +0,0 @@ -package de.unistuttgart.iste.meitrex.template; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -import java.util.Arrays; - -/** - * This is the entry point of the application. - *

- * TODO: Rename the package and the class to match the microservice name. - */ -@SpringBootApplication -@Slf4j -public class TemplateForMicroservicesApplication { - - public static void main(String[] args) { - Arrays.stream(args).map(arg -> "Received argument: " + arg).forEach(log::info); - SpringApplication.run(TemplateForMicroservicesApplication.class, args); - } - -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/template/config/GraphQlScalarsAndValidationConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/template/config/GraphQlScalarsAndValidationConfiguration.java deleted file mode 100644 index fab8829..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/template/config/GraphQlScalarsAndValidationConfiguration.java +++ /dev/null @@ -1,35 +0,0 @@ -package de.unistuttgart.iste.meitrex.template.config; - -import graphql.scalars.ExtendedScalars; -import graphql.validation.rules.OnValidationErrorStrategy; -import graphql.validation.rules.ValidationRules; -import graphql.validation.schemawiring.ValidationSchemaWiring; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.graphql.execution.RuntimeWiringConfigurer; - -/** - * This class sets up the validation rules for the GraphQL schema and the scalar types. - */ -@Configuration -public class GraphQlScalarsAndValidationConfiguration { - - @Bean - public RuntimeWiringConfigurer runtimeWiringConfigurer() { - - ValidationRules validationRules = ValidationRules.newValidationRules() - .onValidationErrorStrategy(OnValidationErrorStrategy.RETURN_NULL) - .build(); - - ValidationSchemaWiring schemaWiring = new ValidationSchemaWiring(validationRules); - - return wiringBuilder -> wiringBuilder - .directiveWiring(schemaWiring) - .scalar(ExtendedScalars.DateTime) - .scalar(ExtendedScalars.Date) - .scalar(ExtendedScalars.Time) - .scalar(ExtendedScalars.LocalTime) - .scalar(ExtendedScalars.UUID) - .scalar(ExtendedScalars.Url); - } -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/template/config/ModelMapperConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/template/config/ModelMapperConfiguration.java deleted file mode 100644 index d01831b..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/template/config/ModelMapperConfiguration.java +++ /dev/null @@ -1,17 +0,0 @@ -package de.unistuttgart.iste.meitrex.template.config; - -import org.modelmapper.ModelMapper; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Provides a bean for the ModelMapper. The model mapper is used to map entities to DTOs and vice versa. - */ -@Configuration -public class ModelMapperConfiguration { - - @Bean - public ModelMapper modelMapper() { - return new ModelMapper(); - } -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/template/config/package-info.java b/src/main/java/de/unistuttgart/iste/meitrex/template/config/package-info.java deleted file mode 100644 index 01d0a9b..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/template/config/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * This package should contain any classes that are used to configure the application. - */ -package de.unistuttgart.iste.meitrex.template.config; \ No newline at end of file diff --git a/src/main/java/de/unistuttgart/iste/meitrex/template/controller/TemplateController.java b/src/main/java/de/unistuttgart/iste/meitrex/template/controller/TemplateController.java deleted file mode 100644 index 4acff08..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/template/controller/TemplateController.java +++ /dev/null @@ -1,25 +0,0 @@ -package de.unistuttgart.iste.meitrex.template.controller; - -import de.unistuttgart.iste.meitrex.generated.dto.Template; -import de.unistuttgart.iste.meitrex.template.service.TemplateService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.graphql.data.method.annotation.QueryMapping; -import org.springframework.stereotype.Controller; - -import java.util.List; - -@Slf4j -@Controller -@RequiredArgsConstructor -public class TemplateController { - - private final TemplateService templateService; - - @QueryMapping - public List