diff --git a/Dockerfile b/Dockerfile index 21557ee..5491d64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ # for dev purposes only FROM kestra/kestra:latest -# COPY build/libs/* /app/plugins/ # this is already handled in docker-compose.yml +COPY build/libs/* /app/plugins/ diff --git a/build.gradle b/build.gradle index e5836a6..19665f4 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ java { } group = "io.kestra.plugin" -description = 'Plugin template for Kestra' +description = 'GitLab plugin for Kestra' tasks.withType(JavaCompile).configureEach { options.encoding = "UTF-8" @@ -96,6 +96,7 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-engine" testImplementation "org.hamcrest:hamcrest" testImplementation "org.hamcrest:hamcrest-library" + testImplementation "org.wiremock:wiremock-standalone:3.5.2" } /**********************************************************************************************************************\ @@ -171,11 +172,11 @@ tasks.withType(GenerateModuleMetadata).configureEach { jar { manifest { attributes( - "X-Kestra-Name": project.name, - "X-Kestra-Title": "Template", - "X-Kestra-Group": project.group + ".templates", - "X-Kestra-Description": project.description, - "X-Kestra-Version": project.version + "X-Kestra-Name": project.name, + "X-Kestra-Title": "GitLab", + "X-Kestra-Group": project.group + ".gitlab", + "X-Kestra-Description": project.description, + "X-Kestra-Version": project.version ) } } diff --git a/settings.gradle b/settings.gradle index aaa347f..4004a59 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'plugin-template' +rootProject.name = 'plugin-gitlab' diff --git a/src/main/java/io/kestra/plugin/gitlab/AbstractGitLabTask.java b/src/main/java/io/kestra/plugin/gitlab/AbstractGitLabTask.java new file mode 100644 index 0000000..8ae3547 --- /dev/null +++ b/src/main/java/io/kestra/plugin/gitlab/AbstractGitLabTask.java @@ -0,0 +1,62 @@ +package io.kestra.plugin.gitlab; + +import io.kestra.core.exceptions.IllegalVariableEvaluationException; +import io.kestra.core.http.HttpRequest; +import io.kestra.core.http.client.HttpClient; +import io.kestra.core.http.client.configurations.HttpConfiguration; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.Task; +import io.kestra.core.runners.RunContext; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.SuperBuilder; + +import java.net.URI; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +public abstract class AbstractGitLabTask extends Task { + + @Schema(title = "GitLab URL", description = "GitLab URL") + @Builder.Default + private Property url = Property.ofValue("https://gitlab.com"); + + @Schema(title = "Personal Access Token", description = "GitLab Personal Access Token") + @NotNull + private Property token; + + @Schema(title = "Project ID", description = "GitLab project ID") + @NotNull + private Property projectId; + + @Schema(title = "API Path", description = "Custom API path for GitLab API endpoints") + @Builder.Default + private Property apiPath = Property.ofValue("/api/v4/projects"); + + protected HttpClient httpClient(RunContext runContext) throws IllegalVariableEvaluationException { + + HttpConfiguration config = null; + return new HttpClient(runContext, config); + } + + protected HttpRequest.HttpRequestBuilder authenticatedRequestBuilder(String endpoint, RunContext runContext) throws IllegalVariableEvaluationException { + String baseUrl = runContext.render(this.url).as(String.class).orElse("https://gitlab.com"); + String renderedToken = runContext.render(this.token).as(String.class).orElseThrow(); + String fullUrl = baseUrl + endpoint; + return HttpRequest.builder() + .uri(URI.create(fullUrl)) + .addHeader("PRIVATE-TOKEN", renderedToken) + .addHeader("Content-Type", "application/json"); + } + + protected String buildApiEndpoint(String resource, RunContext runContext) throws IllegalVariableEvaluationException { + String renderedApiPath = runContext.render(this.apiPath).as(String.class).orElse("/api/v4/projects"); + String renderedProjectId = runContext.render(this.getProjectId()).as(String.class).orElseThrow(); + return renderedApiPath + "/" + renderedProjectId + "/" + resource; + } + +} diff --git a/src/main/java/io/kestra/plugin/gitlab/MergeRequest.java b/src/main/java/io/kestra/plugin/gitlab/MergeRequest.java new file mode 100644 index 0000000..5b2dc03 --- /dev/null +++ b/src/main/java/io/kestra/plugin/gitlab/MergeRequest.java @@ -0,0 +1,119 @@ +package io.kestra.plugin.gitlab; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kestra.core.http.HttpRequest; +import io.kestra.core.http.HttpResponse; +import io.kestra.core.http.client.HttpClient; +import io.kestra.core.models.annotations.Example; +import io.kestra.core.models.annotations.Plugin; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTask; +import io.kestra.core.runners.RunContext; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.SuperBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@Schema( + title = "Create a GitLab merge request", + description = "Create a new merger request in a GitLab project" +) +@Plugin(examples = { + @Example( + title = "Create a merge request in a GitLab project using a project access token.", + full = true, + code = """ + id: gitlab_merge_request + namespace: company.team + + tasks: + - id: create_merge_request + type: io.kestra.plugin.gitlab.MergeRequest + url: https://gitlab.example.com + token: "{{ secret('GITLAB_TOKEN') }}" + projectId: "123" + title: "Feature: Add new functionality" + mergeRequestDescription: "This merge request adds new functionality to the project" + sourceBranch: "feat-testing" + targetBranch: "main" + """ + ) +}) +public class MergeRequest extends AbstractGitLabTask implements RunnableTask { + + @Schema(title = "Merge request title") + @NotNull + private Property title; + + @Schema(title = "Source branch") + @NotNull + private Property sourceBranch; + + @Schema(title = "Target branch") + @NotNull + private Property targetBranch; + + @Schema(title = "Merge request description") + private Property mergeRequestDescription; + + @Override + public Output run(RunContext runContext) throws Exception { + try (HttpClient client = httpClient(runContext)) { + + Map body = new HashMap<>(); + + // Required fields for merge request creation + body.put("title", runContext.render(this.title).as(String.class).orElseThrow()); + + body.put("source_branch", runContext.render(this.sourceBranch).as(String.class).orElseThrow()); + + body.put("target_branch", runContext.render(this.targetBranch).as(String.class).orElseThrow()); + + // Optional fields + if (this.mergeRequestDescription != null) { + body.put("description", runContext.render(this.mergeRequestDescription).as(String.class).orElseThrow()); + } + + ObjectMapper mapper = new ObjectMapper(); + String jsonBody = mapper.writeValueAsString(body); + String endpoint = buildApiEndpoint("merge_requests", runContext); + + HttpRequest request = authenticatedRequestBuilder(endpoint, runContext) + .method("POST") + .body(new HttpRequest.StringRequestBody("application/json", StandardCharsets.UTF_8, jsonBody)) + .build(); + + HttpResponse response = client.request(request, Map.class); + Map result = response.getBody(); + + return Output.builder() + .mergeReqID(result.get("id").toString()) + .webUrl(result.get("web_url").toString()) + .statusCode(response.getStatus().getCode()) + .build(); + } + } + + @Builder + @Getter + public static class Output implements io.kestra.core.models.tasks.Output { + @Schema(title = "Created merge request ID") + private String mergeReqID; + + @Schema(title = "web URL") + private String webUrl; + + @Schema(title = "HTTP status code") + private Integer statusCode; + } + +} diff --git a/src/main/java/io/kestra/plugin/gitlab/issues/Create.java b/src/main/java/io/kestra/plugin/gitlab/issues/Create.java new file mode 100644 index 0000000..595bd9f --- /dev/null +++ b/src/main/java/io/kestra/plugin/gitlab/issues/Create.java @@ -0,0 +1,132 @@ +package io.kestra.plugin.gitlab.issues; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kestra.core.http.HttpRequest; +import io.kestra.core.http.HttpResponse; +import io.kestra.core.http.client.HttpClient; +import io.kestra.core.models.annotations.Example; +import io.kestra.core.models.annotations.Plugin; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTask; +import io.kestra.core.runners.RunContext; +import io.kestra.plugin.gitlab.AbstractGitLabTask; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.SuperBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@Schema( + title = "Create a GitLab issue", + description = "Create a new issue in a GitLab project" +) +@Plugin(examples = { + @Example( + title = "Create an issue in a GitLab project using a project access token.", + full = true, + code = """ + id: gitlab_create_issue + namespace: company.team + + tasks: + - id: create_issue + type: io.kestra.plugin.gitlab.issues.Create + url: https://gitlab.example.com + token: "{{ secret('GITLAB_TOKEN') }}" + projectId: "123" + title: "Bug report" + issueDescription: "Found a critical bug" + labels: + - bug + - critical + """ + ), + @Example( + title = "Create an issue with custom API path for self-hosted GitLab.", + full = true, + code = """ + id: gitlab_create_issue_custom + namespace: company.team + + tasks: + - id: create_issue + type: io.kestra.plugin.gitlab.issues.Create + url: https://gitlab.example.com + apiPath: /api/v4/projects + token: "{{ secret('GITLAB_TOKEN') }}" + projectId: "123" + title: "Bug report" + issueDescription: "Found a critical bug" + """ + ) +}) +public class Create extends AbstractGitLabTask implements RunnableTask { + + @Schema(title = "Issue title") + @NotNull + private Property title; + + @Schema(title = "Issue description") + private Property issueDescription; + + @Schema(title = "Labels to assign to the issue") + private Property> labels; + + @Override + public Output run(RunContext runContext) throws Exception { + try (HttpClient client = httpClient(runContext)) { + + Map body = new HashMap<>(); + body.put("title", runContext.render(this.title).as(String.class).orElseThrow()); + if (this.issueDescription != null) { + body.put("description", runContext.render(this.issueDescription).as(String.class).orElseThrow()); + } + if (this.labels != null) { + List renderedLabels = runContext.render(this.labels).asList(String.class); + body.put("labels", renderedLabels); + } + ObjectMapper mapper = new ObjectMapper(); + String jsonBody = mapper.writeValueAsString(body); + String endpoint = buildApiEndpoint("issues", runContext); + + HttpRequest request = authenticatedRequestBuilder(endpoint, runContext) + .method("POST") + .body(new HttpRequest.StringRequestBody("application/json", + StandardCharsets.UTF_8, + jsonBody)) + .build(); + + HttpResponse response = client.request(request, Map.class); + + Map result = response.getBody(); + + return Output.builder() + .issueId(result.get("id").toString()) + .webUrl(result.get("web_url").toString()) + .statusCode(response.getStatus().getCode()) + .build(); + } + } + + @Builder + @Getter + public static class Output implements io.kestra.core.models.tasks.Output { + @Schema(title = "Created issue ID") + private String issueId; + + @Schema(title = "Issue web URL") + private String webUrl; + + @Schema(title = "HTTP status code") + private Integer statusCode; + } +} \ No newline at end of file diff --git a/src/main/java/io/kestra/plugin/gitlab/issues/Search.java b/src/main/java/io/kestra/plugin/gitlab/issues/Search.java new file mode 100644 index 0000000..5110914 --- /dev/null +++ b/src/main/java/io/kestra/plugin/gitlab/issues/Search.java @@ -0,0 +1,114 @@ +package io.kestra.plugin.gitlab.issues; + +import io.kestra.core.http.HttpRequest; +import io.kestra.core.http.HttpResponse; +import io.kestra.core.http.client.HttpClient; +import io.kestra.core.models.annotations.Example; +import io.kestra.core.models.annotations.Plugin; +import io.kestra.core.models.property.Property; +import io.kestra.core.models.tasks.RunnableTask; +import io.kestra.core.runners.RunContext; +import io.kestra.plugin.gitlab.AbstractGitLabTask; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import lombok.experimental.SuperBuilder; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@SuperBuilder +@ToString +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@Schema( + title = "Search GitLab issues", + description = "Search for issues in a GitLab project" +) +@Plugin(examples = { + @Example( + title = "Search for issues in a GitLab project using a project access token.", + full = true, + code = """ + id: gitlab_search_issues + namespace: company.team + + tasks: + - id: search_issues + type: io.kestra.plugin.gitlab.issues.Search + url: https://gitlab.example.com + token: "{{ secret('GITLAB_TOKEN') }}" + projectId: "123" + search: "bug" + state: "opened" + labels: + - bug + - critical + """ + ) +}) +public class Search extends AbstractGitLabTask implements RunnableTask { + + @Schema(title = "Search query") + private Property search; + + @Schema(title = "Issue state", description = "opened, closed or all") + @Builder.Default + private Property state = Property.ofValue("opened"); + + @Schema(title = "Labels to filter by") + private Property> labels; + + @Override + public Output run(RunContext runContext) throws Exception { + try (HttpClient client = httpClient(runContext)) { + + // Build the query params + List params = new ArrayList<>(); + if (this.search != null) { + String rSearch = runContext.render(this.search).as(String.class).orElseThrow(); + params.add("search=" + URLEncoder.encode(rSearch, StandardCharsets.UTF_8)); + } + String renderedState = runContext.render(this.state).as(String.class).orElse("opened"); + params.add("state=" + renderedState); + if (this.labels != null) { + List renderedLabels = runContext.render(this.labels).asList(String.class); + String labelStr = String.join(",", renderedLabels); + params.add("labels=" + URLEncoder.encode(labelStr, StandardCharsets.UTF_8)); + } + + String queryStr = "?" + String.join("&", params); + String endpoint = buildApiEndpoint("issues", runContext) + queryStr; + + // Create GET request + HttpRequest request = authenticatedRequestBuilder(endpoint, runContext) + .method("GET") + .build(); + + HttpResponse response = client.request(request, List.class); + List> issues = response.getBody(); + + return Output.builder() + .issues(issues) + .count(issues.size()) + .statusCode(response.getStatus().getCode()) + .build(); + } + } + + @Builder + @Getter + public static class Output implements io.kestra.core.models.tasks.Output { + @Schema(title = "Found issues") + private List> issues; + + @Schema(title = "Number of issues found") + private Integer count; + + @Schema(title = "HTTP status code") + private Integer statusCode; + } +} diff --git a/src/main/java/io/kestra/plugin/gitlab/package-info.java b/src/main/java/io/kestra/plugin/gitlab/package-info.java new file mode 100644 index 0000000..e640f0b --- /dev/null +++ b/src/main/java/io/kestra/plugin/gitlab/package-info.java @@ -0,0 +1,7 @@ +@PluginSubGroup( + description = "This sub-group of plugins contains tasks for using GitLab.\n", + categories = PluginSubGroup.PluginCategory.TOOL +) +package io.kestra.plugin.gitlab; + +import io.kestra.core.models.annotations.PluginSubGroup; \ No newline at end of file diff --git a/src/main/java/io/kestra/plugin/templates/Example.java b/src/main/java/io/kestra/plugin/templates/Example.java deleted file mode 100644 index a760828..0000000 --- a/src/main/java/io/kestra/plugin/templates/Example.java +++ /dev/null @@ -1,72 +0,0 @@ -package io.kestra.plugin.templates; - -import io.kestra.core.models.annotations.Plugin; -import io.kestra.core.models.property.Property; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; -import lombok.experimental.SuperBuilder; -import org.apache.commons.lang3.StringUtils; -import io.kestra.core.models.tasks.RunnableTask; -import io.kestra.core.models.tasks.Task; -import io.kestra.core.runners.RunContext; -import org.slf4j.Logger; - -@SuperBuilder -@ToString -@EqualsAndHashCode -@Getter -@NoArgsConstructor -@Schema( - title = "Short description for this task", - description = "Full description of this task" -) -@Plugin( - examples = { - @io.kestra.core.models.annotations.Example( - title = "Simple revert", - code = { "format: \"Text to be reverted\"" } - ) - } -) -public class Example extends Task implements RunnableTask { - @Schema( - title = "Short description for this input", - description = "Full description of this input" - ) - private Property format; - - @Override - public Example.Output run(RunContext runContext) throws Exception { - Logger logger = runContext.logger(); - - String render = runContext.render(format).as(String.class).orElse(""); - logger.debug(render); - - return Output.builder() - .child(new OutputChild(StringUtils.reverse(render))) - .build(); - } - - /** - * Input or Output can be nested as you need - */ - @Builder - @Getter - public static class Output implements io.kestra.core.models.tasks.Output { - @Schema( - title = "Short description for this output", - description = "Full description of this output" - ) - private final OutputChild child; - } - - @Builder - @Getter - public static class OutputChild implements io.kestra.core.models.tasks.Output { - @Schema( - title = "Short description for this output", - description = "Full description of this output" - ) - private final String value; - } -} diff --git a/src/main/java/io/kestra/plugin/templates/Trigger.java b/src/main/java/io/kestra/plugin/templates/Trigger.java deleted file mode 100644 index 7971aa1..0000000 --- a/src/main/java/io/kestra/plugin/templates/Trigger.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.kestra.plugin.templates; - -import io.kestra.core.exceptions.IllegalVariableEvaluationException; -import io.kestra.core.models.annotations.Plugin; -import io.kestra.core.models.conditions.ConditionContext; -import io.kestra.core.models.executions.Execution; -import io.kestra.core.models.property.Property; -import io.kestra.core.models.triggers.*; -import io.kestra.core.runners.RunContext; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.*; -import lombok.experimental.SuperBuilder; - -import java.time.Duration; -import java.util.Optional; - -@SuperBuilder -@ToString -@EqualsAndHashCode -@Getter -@NoArgsConstructor -@Plugin -@Schema( - title = "Trigger an execution randomly", - description ="Trigger an execution randomly" -) -public class Trigger extends AbstractTrigger implements PollingTriggerInterface, TriggerOutput { - @Builder.Default - private final Duration interval = Duration.ofSeconds(60); - - protected Property min = Property.of(0.5); - - @Override - public Optional evaluate(ConditionContext conditionContext, TriggerContext context) throws IllegalVariableEvaluationException { - RunContext runContext = conditionContext.getRunContext(); - - double random = Math.random(); - if (random < runContext.render(this.min).as(Double.class).orElseThrow()) { - return Optional.empty(); - } - - runContext.logger().info("Will create an execution"); - Execution execution = TriggerService.generateExecution( - this, - conditionContext, - context, - Output.builder().random(random).build() - ); - - return Optional.of(execution); - } - - @Builder - @Getter - public static class Output implements io.kestra.core.models.tasks.Output { - private Double random; - } -} diff --git a/src/main/java/io/kestra/plugin/templates/package-info.java b/src/main/java/io/kestra/plugin/templates/package-info.java deleted file mode 100644 index 50e452d..0000000 --- a/src/main/java/io/kestra/plugin/templates/package-info.java +++ /dev/null @@ -1,8 +0,0 @@ -@PluginSubGroup( - title = "Example plugin", - description = "A plugin to show how to build a plugin in Kestra.", - categories = PluginSubGroup.PluginCategory.TOOL -) -package io.kestra.plugin.templates; - -import io.kestra.core.models.annotations.PluginSubGroup; \ No newline at end of file diff --git a/src/main/resources/icons/io.kestra.plugin.gitlab.issues.svg b/src/main/resources/icons/io.kestra.plugin.gitlab.issues.svg new file mode 100644 index 0000000..c27c575 --- /dev/null +++ b/src/main/resources/icons/io.kestra.plugin.gitlab.issues.svg @@ -0,0 +1,37 @@ + + \ No newline at end of file diff --git a/src/main/resources/icons/io.kestra.plugin.gitlab.svg b/src/main/resources/icons/io.kestra.plugin.gitlab.svg new file mode 100644 index 0000000..d6ee3fd --- /dev/null +++ b/src/main/resources/icons/io.kestra.plugin.gitlab.svg @@ -0,0 +1,37 @@ + + \ No newline at end of file diff --git a/src/main/resources/icons/plugin-icon.svg b/src/main/resources/icons/plugin-icon.svg new file mode 100644 index 0000000..d6ee3fd --- /dev/null +++ b/src/main/resources/icons/plugin-icon.svg @@ -0,0 +1,37 @@ + + \ No newline at end of file diff --git a/src/test/java/io/kestra/plugin/gitlab/AbstractGitLabTest.java b/src/test/java/io/kestra/plugin/gitlab/AbstractGitLabTest.java new file mode 100644 index 0000000..7a8b2e6 --- /dev/null +++ b/src/test/java/io/kestra/plugin/gitlab/AbstractGitLabTest.java @@ -0,0 +1,19 @@ +package io.kestra.plugin.gitlab; + +import io.kestra.core.junit.annotations.KestraTest; +import io.micronaut.context.annotation.Value; + + +@KestraTest +public abstract class AbstractGitLabTest { + + @Value("${kestra.gitlab.url}") + private String url; + + + @Value("${kestra.gitlab.token}") + private String token; + + @Value("${kestra.gitlab.projectId") + private String projectId; +} diff --git a/src/test/java/io/kestra/plugin/gitlab/CreateIssueTest.java b/src/test/java/io/kestra/plugin/gitlab/CreateIssueTest.java new file mode 100644 index 0000000..9ba0290 --- /dev/null +++ b/src/test/java/io/kestra/plugin/gitlab/CreateIssueTest.java @@ -0,0 +1,120 @@ +package io.kestra.plugin.gitlab; + +import io.kestra.core.models.property.Property; +import io.kestra.core.runners.RunContext; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.plugin.gitlab.issues.Create; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CreateIssueTest extends WireMockTest { + @Inject + private RunContextFactory runContextFactory; + + @Test + void testCreateIssue() throws Exception { + // Mock the GitLab API endpoint for creating an issue + wireMock.stubFor(post(urlEqualTo("/api/v4/projects/12345/issues")) + .withRequestBody(equalToJson("{\"title\":\"Test issue\",\"description\":\"This is a test issue\"}")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"id\":1,\"iid\":1,\"project_id\":12345,\"title\":\"Test issue\",\"web_url\":\"https://gitlab.example.com/test-group/test-project/issues/1\"}") + )); + + RunContext runContext = runContextFactory.of(); + + Create task = Create.builder() + .id("create-issue") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test issue")) + .issueDescription(Property.of("This is a test issue")) + .build(); + + Create.Output runOutput = task.run(runContext); + + assertThat(runOutput.getIssueId(), is(notNullValue())); + assertThat(runOutput.getWebUrl(), is(notNullValue())); + } + + @Test + void testCreateIssueNotFound() { + // Mock the GitLab API endpoint for a non-existent project + wireMock.stubFor(post(urlEqualTo("/api/v4/projects/54321/issues")) + .willReturn(notFound())); + + RunContext runContext = runContextFactory.of(); + + Create task = Create.builder() + .id("create-issue") + .projectId(Property.of("54321")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test issue")) + .issueDescription(Property.of("This is a test issue")) + .build(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } + + @Test + void testCreateIssue_missingProjectId() throws Exception { + RunContext runContext = runContextFactory.of(); + + Create task = Create.builder() + .id("create-issue") + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test issue")) + .build(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } + + @Test + void testCreateIssue_missingToken() throws Exception { + RunContext runContext = runContextFactory.of(); + + Create task = Create.builder() + .id("create-issue") + .projectId(Property.of("12345")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test issue")) + .build(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } + + @Test + void testCreateIssueWithMinimalData() throws Exception { + // Mock endpoint for minimal data + wireMock.stubFor(post(urlEqualTo("/api/v4/projects/12345/issues")) + .withRequestBody(equalToJson("{\"title\":\"Minimal Issue\"}")) + .willReturn(aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody("{\"id\":2,\"web_url\":\"https://gitlab.example.com/test/issues/2\"}"))); + + RunContext runContext = runContextFactory.of(); + + Create task = Create.builder() + .id("create-issue") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Minimal Issue")) + .build(); + + Create.Output runOutput = task.run(runContext); + assertThat(runOutput.getIssueId(), is("2")); + assertThat(runOutput.getWebUrl(), is("https://gitlab.example.com/test/issues/2")); + } +} + diff --git a/src/test/java/io/kestra/plugin/gitlab/MergeRequestTest.java b/src/test/java/io/kestra/plugin/gitlab/MergeRequestTest.java new file mode 100644 index 0000000..646dd9f --- /dev/null +++ b/src/test/java/io/kestra/plugin/gitlab/MergeRequestTest.java @@ -0,0 +1,179 @@ +package io.kestra.plugin.gitlab; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kestra.core.models.property.Property; +import io.kestra.core.runners.RunContext; +import io.kestra.core.runners.RunContextFactory; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class MergeRequestTest extends WireMockTest { + @Inject + private RunContextFactory runContextFactory; + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void testCreateMergeRequest() throws Exception { + // Mock the GitLab API endpoint for creating a merge request + wireMock.stubFor(post(urlEqualTo("/api/v4/projects/12345/merge_requests")) + .withRequestBody(equalToJson("{\"title\":\"Test merge request\",\"description\":\"This is a test merge request\",\"source_branch\":\"feature/test-branch\",\"target_branch\":\"main\"}")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\"id\":1,\"iid\":1,\"project_id\":12345,\"title\":\"Test merge request\",\"web_url\":\"https://gitlab.example.com/test-group/test-project/merge_requests/1\"}"))); + + RunContext runContext = runContextFactory.of(); + + MergeRequest task = MergeRequest.builder() + .id("create-merge-request") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test merge request")) + .mergeRequestDescription(Property.of("This is a test merge request")) + .sourceBranch(Property.of("feature/test-branch")) + .targetBranch(Property.of("main")) + .build(); + + MergeRequest.Output runOutput = task.run(runContext); + + assertThat(runOutput.getMergeReqID(), is(notNullValue())); + assertThat(runOutput.getWebUrl(), is(notNullValue())); + } + + @Test + void testCreateMergeRequestNotFound() { + // Mock the GitLab API endpoint for a non-existent project + wireMock.stubFor(post(urlEqualTo("/api/v4/projects/54321/merge_requests")) + .willReturn(notFound())); + + RunContext runContext = runContextFactory.of(); + + MergeRequest task = MergeRequest.builder() + .id("create-merge-request") + .projectId(Property.of("54321")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test merge request")) + .mergeRequestDescription(Property.of("This is a test merge request")) + .sourceBranch(Property.of("feature/test-branch")) + .targetBranch(Property.of("main")) + .build(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } + + @Test + void testCreateMergeRequest_missingProjectId() throws Exception { + RunContext runContext = runContextFactory.of(); + + MergeRequest task = MergeRequest.builder() + .id("create-merge-request") + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test merge request")) + .sourceBranch(Property.of("feature/test-branch")) + .targetBranch(Property.of("main")) + .build(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } + + @Test + void testCreateMergeRequest_missingToken() throws Exception { + RunContext runContext = runContextFactory.of(); + + MergeRequest task = MergeRequest.builder() + .id("create-merge-request") + .projectId(Property.of("12345")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test merge request")) + .sourceBranch(Property.of("feature/test-branch")) + .targetBranch(Property.of("main")) + .build(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } + + @Test + void testCreateMergeRequestWithEmptyRequiredFields() throws Exception { + // Mock endpoint that should not be called + wireMock.stubFor(post(urlMatching("/api/v4/projects/.*/merge_requests")) + .willReturn(aResponse().withStatus(400))); + + RunContext runContext = runContextFactory.of(); + + // Test with null title - should create MR but GitLab API will likely fail + MergeRequest task = MergeRequest.builder() + .id("create-merge-request") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .sourceBranch(Property.of("feature/test-branch")) + .targetBranch(Property.of("main")) + .build(); + + // This should complete without throwing since title is optional in our implementation + // The actual validation happens at GitLab API level + assertThrows(Exception.class, () -> task.run(runContext)); + } + + @Test + void testCreateMergeRequestWithMinimalData() throws Exception { + // Mock the GitLab API endpoint for creating a merge request with minimal data + wireMock.stubFor(post(urlEqualTo("/api/v4/projects/12345/merge_requests")) + .withRequestBody(equalToJson("{\"title\":\"Minimal MR\",\"source_branch\":\"feature\",\"target_branch\":\"main\"}")) + .willReturn(aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody("{\"id\":2,\"web_url\":\"https://gitlab.example.com/test/merge_requests/2\"}"))); + + RunContext runContext = runContextFactory.of(); + + MergeRequest task = MergeRequest.builder() + .id("create-merge-request") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Minimal MR")) + .sourceBranch(Property.of("feature")) + .targetBranch(Property.of("main")) + .build(); + + MergeRequest.Output runOutput = task.run(runContext); + + assertThat(runOutput.getMergeReqID(), is("2")); + assertThat(runOutput.getWebUrl(), is("https://gitlab.example.com/test/merge_requests/2")); + assertThat(runOutput.getStatusCode(), is(201)); + } + + @Test + void testCreateMergeRequestBadRequest() { + // Mock the GitLab API endpoint returning bad request + wireMock.stubFor(post(urlEqualTo("/api/v4/projects/12345/merge_requests")) + .willReturn(aResponse() + .withStatus(400) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\":\"Bad request\"}"))); + + RunContext runContext = runContextFactory.of(); + + MergeRequest task = MergeRequest.builder() + .id("create-merge-request") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .title(Property.of("Test merge request")) + .sourceBranch(Property.of("feature/test-branch")) + .targetBranch(Property.of("main")) + .build(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } +} diff --git a/src/test/java/io/kestra/plugin/gitlab/SearchIssuesTest.java b/src/test/java/io/kestra/plugin/gitlab/SearchIssuesTest.java new file mode 100644 index 0000000..79ab9c9 --- /dev/null +++ b/src/test/java/io/kestra/plugin/gitlab/SearchIssuesTest.java @@ -0,0 +1,95 @@ + +package io.kestra.plugin.gitlab; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kestra.core.models.property.Property; +import io.kestra.core.runners.RunContext; +import io.kestra.core.runners.RunContextFactory; +import io.kestra.plugin.gitlab.issues.Search; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SearchIssuesTest extends WireMockTest { + @Inject + private RunContextFactory runContextFactory; + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void testSearchIssues() throws Exception { + // Mock the GitLab API endpoint for searching issues + wireMock.stubFor(get(urlEqualTo("/api/v4/projects/12345/issues?search=Test+issue&state=opened")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("[{\"id\":1,\"iid\":1,\"project_id\":12345,\"title\":\"Test issue\",\"web_url\":\"https://gitlab.example.com/test-group/test-project/issues/1\"}]") + )); + + Search task = Search.builder() + .id("search-issues") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .search(Property.of("Test issue")) + .build(); + + RunContext runContext = runContextFactory.of(); + + Search.Output runOutput = task.run(runContext); + + assertThat(runOutput.getCount(), is(1)); + assertThat(runOutput.getIssues(), is(notNullValue())); + } + + @Test + void testSearchIssuesWithState() throws Exception { + // Mock the GitLab API endpoint for searching issues + wireMock.stubFor(get(urlEqualTo("/api/v4/projects/12345/issues?search=Test+issue&state=closed")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("[{\"id\":1,\"iid\":1,\"project_id\":12345,\"title\":\"Test issue\",\"web_url\":\"https://gitlab.example.com/test-group/test-project/issues/1\"}]") + )); + + Search task = Search.builder() + .id("search-issues") + .projectId(Property.of("12345")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .search(Property.of("Test issue")) + .state(Property.of("closed")) + .build(); + + RunContext runContext = runContextFactory.of(); + + Search.Output runOutput = task.run(runContext); + + assertThat(runOutput.getCount(), is(1)); + assertThat(runOutput.getIssues(), is(notNullValue())); + } + + @Test + void testSearchIssuesNotFound() { + // Mock the GitLab API endpoint for a non-existent project + wireMock.stubFor(get(urlEqualTo("/api/v4/projects/54321/issues?search=Test+issue&state=opened")) + .willReturn(notFound())); + + Search task = Search.builder() + .id("search-issues") + .projectId(Property.of("54321")) + .token(Property.of("test-token")) + .url(Property.of(wireMock.baseUrl())) + .search(Property.of("Test issue")) + .build(); + + RunContext runContext = runContextFactory.of(); + + assertThrows(Exception.class, () -> task.run(runContext)); + } + + +} diff --git a/src/test/java/io/kestra/plugin/gitlab/WireMockTest.java b/src/test/java/io/kestra/plugin/gitlab/WireMockTest.java new file mode 100644 index 0000000..46c4bf2 --- /dev/null +++ b/src/test/java/io/kestra/plugin/gitlab/WireMockTest.java @@ -0,0 +1,16 @@ + +package io.kestra.plugin.gitlab; + +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import io.kestra.core.junit.annotations.KestraTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +@KestraTest +public abstract class WireMockTest { + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort()) + .build(); +} diff --git a/src/test/java/io/kestra/plugin/templates/ExampleRunnerTest.java b/src/test/java/io/kestra/plugin/templates/ExampleRunnerTest.java deleted file mode 100644 index e452654..0000000 --- a/src/test/java/io/kestra/plugin/templates/ExampleRunnerTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.kestra.plugin.templates; - -import io.kestra.core.junit.annotations.ExecuteFlow; -import io.kestra.core.junit.annotations.KestraTest; -import io.kestra.core.queues.QueueException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import io.kestra.core.models.executions.Execution; -import io.kestra.core.repositories.LocalFlowRepositoryLoader; -import io.kestra.core.runners.RunnerUtils; -import io.kestra.core.runners.StandAloneRunner; - -import jakarta.inject.Inject; -import java.io.IOException; -import java.net.URISyntaxException; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.TimeoutException; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.hasSize; -import static org.hamcrest.Matchers.is; - -/** - * This test will load all flow located in `src/test/resources/flows/` - * and will run an in-memory runner to be able to test a full flow. There is also a - * configuration file in `src/test/resources/application.yml` that is only for the full runner - * test to configure in-memory runner. - */ -@KestraTest(startRunner = true) -class ExampleRunnerTest { - @SuppressWarnings("unchecked") - @Test - @ExecuteFlow("flows/example.yaml") - void flow(Execution execution) throws TimeoutException, QueueException { - assertThat(execution.getTaskRunList(), hasSize(3)); - assertThat(((Map)execution.getTaskRunList().get(2).getOutputs().get("child")).get("value"), is("task-id")); - } -} diff --git a/src/test/java/io/kestra/plugin/templates/ExampleTest.java b/src/test/java/io/kestra/plugin/templates/ExampleTest.java deleted file mode 100644 index d2e8d38..0000000 --- a/src/test/java/io/kestra/plugin/templates/ExampleTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.kestra.plugin.templates; - -import io.kestra.core.junit.annotations.KestraTest; -import io.kestra.core.models.property.Property; -import org.apache.commons.lang3.StringUtils; -import org.junit.jupiter.api.Test; -import io.kestra.core.runners.RunContext; -import io.kestra.core.runners.RunContextFactory; - -import jakarta.inject.Inject; - -import java.util.Map; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; - -/** - * This test will only test the main task, this allow you to send any input - * parameters to your task and test the returning behaviour easily. - */ -@KestraTest -class ExampleTest { - @Inject - private RunContextFactory runContextFactory; - - @Test - void run() throws Exception { - RunContext runContext = runContextFactory.of(Map.of("variable", "John Doe")); - - Example task = Example.builder() - .format(new Property<>("Hello {{ variable }}")) - .build(); - - Example.Output runOutput = task.run(runContext); - - assertThat(runOutput.getChild().getValue(), is(StringUtils.reverse("Hello John Doe"))); - } -} diff --git a/src/test/resources/allure.properties b/src/test/resources/allure.properties deleted file mode 100644 index 4873f6d..0000000 --- a/src/test/resources/allure.properties +++ /dev/null @@ -1 +0,0 @@ -allure.results.directory=build/allure-results diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 636ef67..c0cb70e 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -6,4 +6,4 @@ kestra: storage: type: local local: - base-path: /tmp/unittest + base-path: /tmp/unittest \ No newline at end of file diff --git a/src/test/resources/flows/example.yaml b/src/test/resources/flows/example.yaml deleted file mode 100644 index c475b05..0000000 --- a/src/test/resources/flows/example.yaml +++ /dev/null @@ -1,13 +0,0 @@ -id: example -namespace: io.kestra.templates - -tasks: -- id: date - type: io.kestra.plugin.templates.Example - format: "{{taskrun.startDate}}" -- id: task-id - type: io.kestra.plugin.templates.Example - format: "{{task.id}}" -- id: flow-id - type: io.kestra.plugin.templates.Example - format: "{{outputs['task-id'].child.value}}" diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml deleted file mode 100644 index 803c82e..0000000 --- a/src/test/resources/logback.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - -