diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 0792c56..6905d5c 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -21,10 +21,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 19 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '19' + java-version: '21' distribution: 'temurin' cache: maven - name: Build with Maven diff --git a/coverage/pom.xml b/coverage/pom.xml index 4d02247..89ca2ea 100644 --- a/coverage/pom.xml +++ b/coverage/pom.xml @@ -92,6 +92,11 @@ semver-release-gitlab ${project.version} + + io.github.sam42r + semver-release-codeberg + ${project.version} + io.github.sam42r diff --git a/docs/README.md b/docs/README.md index e81fb9b..3f214cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -217,7 +217,7 @@ or by setting corresponding system property. | | renderCommitLinks | | `true` | [true|false] | Generate commit links | | | renderReleaseLinks | | `true` | [true|false] | Generate release links | | | renderBody | | `false` | [true|false] | Include commit message body | -| release | publisher-name | | `GitHub` | [Github|Gitlab] | Release publisher | +| release | publisher-name | | `GitHub` | [Github|Gitlab|Codeberg] | Release publisher | | | publish | | `false` | [true|false] | publish release to remote | | | username | | | | | | | password | | | | | diff --git a/pom.xml b/pom.xml index a1b46a0..a553a90 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ semver-release-api semver-release-github semver-release-gitlab + semver-release-codeberg semver-maven-plugin coverage diff --git a/semver-maven-plugin/src/test/java/io/github/sam42r/semver/util/BundleHelper.java b/semver-maven-plugin/src/test/java/io/github/sam42r/semver/util/BundleHelper.java index c80b279..b9c8af4 100644 --- a/semver-maven-plugin/src/test/java/io/github/sam42r/semver/util/BundleHelper.java +++ b/semver-maven-plugin/src/test/java/io/github/sam42r/semver/util/BundleHelper.java @@ -27,7 +27,8 @@ public class BundleHelper { "semver-scm-subversion", "semver-release-api", "semver-release-github", - "semver-release-gitlab" + "semver-release-gitlab", + "semver-release-codeberg" }; public static void main(String[] args) { diff --git a/semver-release-api/pom.xml b/semver-release-api/pom.xml index 7dc9233..9162ad2 100644 --- a/semver-release-api/pom.xml +++ b/semver-release-api/pom.xml @@ -14,8 +14,8 @@ ${project.groupId}:${project.artifactId} - 19 - 19 + 21 + 21 UTF-8 @@ -30,5 +30,10 @@ lombok provided + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + diff --git a/semver-release-api/src/main/java/io/github/sam24r/semver/release/AbstractRestApiPublisher.java b/semver-release-api/src/main/java/io/github/sam24r/semver/release/AbstractRestApiPublisher.java new file mode 100644 index 0000000..1333ae9 --- /dev/null +++ b/semver-release-api/src/main/java/io/github/sam24r/semver/release/AbstractRestApiPublisher.java @@ -0,0 +1,60 @@ +package io.github.sam24r.semver.release; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.sam42r.semver.model.release.ReleaseInfo; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +@RequiredArgsConstructor +public abstract class AbstractRestApiPublisher implements ReleasePublisher { + + @Getter + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final String baseUrl; + + @Override + public void publish(@NonNull String scheme, @NonNull String instance, @NonNull String group, @NonNull String project, @NonNull ReleaseInfo releaseInfo) throws ReleaseException { + var projectPath = "%s/%s".formatted(group, project); + var encodedProjectPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + var uri = URI.create(baseUrl.formatted(scheme, instance, encodedProjectPath)); + var payload = generatePayload(releaseInfo); + + try (var httpClient = HttpClient.newHttpClient()) { + var json = objectMapper.writeValueAsString(payload); + + var httpRequest = HttpRequest.newBuilder() + .uri(uri) + .headers(headers()) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + + var response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 201) { + throw new ReleaseException("Release API does return with HTTP-%d - %s".formatted( + response.statusCode(), getErrorMessage(response.body()))); + } + } catch (IOException e) { + throw new ReleaseException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ReleaseException(e); + } + } + + protected abstract Object generatePayload(ReleaseInfo releaseInfo); + + protected abstract String[] headers(); + + protected abstract String getErrorMessage(String responseBody) throws IOException; +} diff --git a/semver-release-codeberg/pom.xml b/semver-release-codeberg/pom.xml new file mode 100644 index 0000000..d8235e8 --- /dev/null +++ b/semver-release-codeberg/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + + io.github.sam42r + semver-parent + 1.9.1 + + + semver-release-codeberg + ${project.groupId}:${project.artifactId} + + + 21 + 21 + UTF-8 + + + + + io.github.sam42r + semver-release-api + ${project.version} + + + + javax.inject + javax.inject + provided + + + org.projectlombok + lombok + provided + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-inline + 4.8.1 + test + + + org.assertj + assertj-core + test + + + org.mock-server + mockserver-junit-jupiter-no-dependencies + test + + + + \ No newline at end of file diff --git a/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/CodebergPublisher.java b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/CodebergPublisher.java new file mode 100644 index 0000000..7da34f1 --- /dev/null +++ b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/CodebergPublisher.java @@ -0,0 +1,52 @@ +package io.github.sam42r.semver.release; + +import io.github.sam24r.semver.release.AbstractRestApiPublisher; +import io.github.sam42r.semver.model.release.ProviderSpec; +import io.github.sam42r.semver.model.release.ReleaseInfo; +import io.github.sam42r.semver.release.model.ApiError; +import io.github.sam42r.semver.release.model.CreateReleaseOption; + +import java.io.IOException; + +public class CodebergPublisher extends AbstractRestApiPublisher { + + private final String token; + + public CodebergPublisher(String baseUrl, String token) { + super(baseUrl); + this.token = token; + } + + @Override + protected Object generatePayload(ReleaseInfo releaseInfo) { + return CreateReleaseOption.builder() + .tagName(releaseInfo.tagName()) + .name(releaseInfo.name()) + .body(releaseInfo.description()) + .build(); + } + + @Override + protected String[] headers() { + return new String[]{ + "Content-Type", "application/json", + "Accept", "application/json", + "Authorization", token + }; + } + + @Override + protected String getErrorMessage(String responseBody) throws IOException { + var apiError = getObjectMapper().readValue(responseBody, ApiError.class); + return apiError.getMessage(); + } + + @Override + public ProviderSpec providerSpec() { + return new ProviderSpec( + "%s://%s/%s/%s/issues/%s", + "%s://%s/%s/%s/commit/%s", + "%s://%s/%s/%s/compare/%s...%s" + ); + } +} diff --git a/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/CodebergPublisherFactory.java b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/CodebergPublisherFactory.java new file mode 100644 index 0000000..23893ac --- /dev/null +++ b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/CodebergPublisherFactory.java @@ -0,0 +1,20 @@ +package io.github.sam42r.semver.release; + +import io.github.sam24r.semver.release.ReleasePublisher; +import lombok.NonNull; + +import javax.inject.Named; + +@Named("Codeberg") +public class CodebergPublisherFactory implements io.github.sam24r.semver.release.ReleasePublisherFactory { + + @Override + public @NonNull ReleasePublisher getInstance(String username, String password) { + return getInstance("%s://%s/api/v1/repos/%s/releases", username, password); + } + + @Override + public @NonNull ReleasePublisher getInstance(@NonNull String baseUrl, String username, String password) { + return new CodebergPublisher(baseUrl, password); + } +} diff --git a/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/model/ApiError.java b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/model/ApiError.java new file mode 100644 index 0000000..5b25bab --- /dev/null +++ b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/model/ApiError.java @@ -0,0 +1,21 @@ +package io.github.sam42r.semver.release.model; + +import lombok.Data; + +import java.util.List; + +/** + * Generic API error used for: + * + */ +@Data +public class ApiError { + + private String message; + private String url; + private List errors; +} diff --git a/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/model/CreateReleaseOption.java b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/model/CreateReleaseOption.java new file mode 100644 index 0000000..ff952d1 --- /dev/null +++ b/semver-release-codeberg/src/main/java/io/github/sam42r/semver/release/model/CreateReleaseOption.java @@ -0,0 +1,28 @@ +package io.github.sam42r.semver.release.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +/** + * CreateReleaseOption options when creating a release. + */ +@Data +@Builder +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CreateReleaseOption { + + private String body; + private boolean draft; + @JsonProperty("hide_archive_links") + private boolean hideArchiveLinks; + private String name; + private boolean prerelease; + @JsonProperty("tag_name") + private String tagName; + @JsonProperty("target_commitish") + private String targetCommitish; +} diff --git a/semver-release-codeberg/src/test/java/io/github/sam42r/semver/release/CodebergPublisherTest.java b/semver-release-codeberg/src/test/java/io/github/sam42r/semver/release/CodebergPublisherTest.java new file mode 100644 index 0000000..2fe2542 --- /dev/null +++ b/semver-release-codeberg/src/test/java/io/github/sam42r/semver/release/CodebergPublisherTest.java @@ -0,0 +1,149 @@ +package io.github.sam42r.semver.release; + +import io.github.sam24r.semver.release.ReleaseException; +import io.github.sam24r.semver.release.ReleasePublisher; +import io.github.sam42r.semver.model.release.ReleaseInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; +import org.mockserver.client.MockServerClient; +import org.mockserver.junit.jupiter.MockServerExtension; +import org.mockserver.matchers.Times; +import org.mockserver.verify.VerificationTimes; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.LocalDateTime; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +@ExtendWith(MockServerExtension.class) +class CodebergPublisherTest { + + private ReleasePublisher uut; + + @BeforeEach + void setup(MockServerClient client) { + uut = new CodebergPublisherFactory().getInstance(null, "token"); + client.reset(); + } + + @Test + void shouldCreateRelease(MockServerClient client) throws ReleaseException { + client.when(request(), Times.exactly(1)) + .respond(response() + .withStatusCode(201) + .withBody(""" + { + "tag_name": "v1.0.0", + "name": "v1.0.0", + "created_at": "2024-01-01T12:00:00.000Z", + "published_at": "2024-01-01T12:00:00.000Z" + } + """) + ); + + var release = new ReleaseInfo( + "v1.0.0", + "v1.0.0", + "# Release v1.0.0", + LocalDateTime.now() + ); + + uut.publish( + "http", + "localhost:%d".formatted(client.getPort()), + "JUnit", + "Test", + release); + + client.verify(request().withMethod("POST") + .withPath("/api/v1/repos/JUnit%2FTest/releases") + .withHeader("Content-Type", "application/json") + .withHeader("Accept", "application/json") + .withHeader("Authorization", "token"), + VerificationTimes.exactly(1)); + } + + @Test + void shouldThrowOnHttpError(MockServerClient client) { + client.when(request(), Times.exactly(1)) + .respond(response() + .withStatusCode(404) + .withBody(""" + { + "message": "GetUserByName", + "url": "https://codeberg.org/api/swagger", + "errors": [ + "user redirect does not exist [name: junit]" + ] + } + """) + ); + + var release = new ReleaseInfo( + "v1.0.0", + "v1.0.0", + "# Release v1.0.0", + LocalDateTime.now() + ); + + assertThatThrownBy(() -> uut.publish( + "http", + "localhost:%d".formatted(client.getPort()), + "JUnit", + "Test", + release)) + .isInstanceOf(ReleaseException.class) + .hasMessage("Release API does return with HTTP-404 - GetUserByName"); + + client.verify(request().withMethod("POST") + .withPath("/api/v1/repos/JUnit%2FTest/releases") + .withHeader("Content-Type", "application/json") + .withHeader("Accept", "application/json") + .withHeader("Authorization", "token"), + VerificationTimes.exactly(1)); + } + + @ParameterizedTest + @MethodSource("thrownExceptions") + void shouldThrowOnException(Exception thrownException, String expectedMessage, MockServerClient client) throws IOException, InterruptedException { + try (var httpClientStatic = mockStatic(HttpClient.class)) { + var httpClient = mock(HttpClient.class); + when(httpClient.send(Mockito.any(), Mockito.any())).thenThrow(thrownException); + + httpClientStatic.when(HttpClient::newHttpClient).thenReturn(httpClient); + + var release = new ReleaseInfo( + "v1.0.0", + "v1.0.0", + "# Release v1.0.0", + LocalDateTime.now() + ); + + assertThatThrownBy(() -> uut.publish( + "http", + "localhost:%d".formatted(client.getPort()), + "JUnit", + "Test", + release)) + .isInstanceOf(ReleaseException.class) + .hasMessage(expectedMessage); + } + } + + private static Stream thrownExceptions() { + return Stream.of( + Arguments.of(new IOException("JUnit"), "java.io.IOException: JUnit"), + Arguments.of(new InterruptedException("JUnit"), "java.lang.InterruptedException: JUnit") + ); + } +} diff --git a/semver-release-gitlab/pom.xml b/semver-release-gitlab/pom.xml index 9a5cf5f..ce1c70a 100644 --- a/semver-release-gitlab/pom.xml +++ b/semver-release-gitlab/pom.xml @@ -14,8 +14,8 @@ ${project.groupId}:${project.artifactId} - 19 - 19 + 21 + 21 UTF-8 @@ -40,16 +40,17 @@ org.slf4j slf4j-api - - com.fasterxml.jackson.core - jackson-databind - 2.17.2 - org.junit.jupiter junit-jupiter test + + org.mockito + mockito-inline + 4.8.1 + test + org.assertj assertj-core diff --git a/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/GitlabPublisher.java b/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/GitlabPublisher.java index c621e1f..3ab5f6c 100644 --- a/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/GitlabPublisher.java +++ b/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/GitlabPublisher.java @@ -1,22 +1,10 @@ package io.github.sam42r.semver.release; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.sam24r.semver.release.ReleaseException; +import io.github.sam24r.semver.release.AbstractRestApiPublisher; import io.github.sam24r.semver.release.ReleasePublisher; import io.github.sam42r.semver.model.release.ProviderSpec; import io.github.sam42r.semver.model.release.ReleaseInfo; import io.github.sam42r.semver.release.model.GitlabRelease; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; /** * {@link ReleasePublisher} for GitLab.
@@ -24,51 +12,17 @@ * @see * Releases API | GitLab */ -@Slf4j -@RequiredArgsConstructor -public class GitlabPublisher implements ReleasePublisher { - - private final ObjectMapper objectMapper = new ObjectMapper(); +public class GitlabPublisher extends AbstractRestApiPublisher { - private final String baseUrl; private final String token; - @Override - public void publish( - @NonNull String scheme, - @NonNull String instance, - @NonNull String group, - @NonNull String project, - @NonNull ReleaseInfo releaseInfo - ) throws ReleaseException { - var projectPath = "%s/%s".formatted(group, project); - var encodedProjectPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); - var uri = URI.create(baseUrl.formatted(scheme, instance, encodedProjectPath)); - var payload = generatePayload(releaseInfo); - - try { - var json = objectMapper.writeValueAsString(payload); - - var httpRequest = HttpRequest.newBuilder() - .uri(uri) - .header("Content-Type", "application/json") - .header("PRIVATE-TOKEN", token) - .POST(HttpRequest.BodyPublishers.ofString(json)) - .build(); - - var response = HttpClient.newHttpClient() - .send(httpRequest, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 201) { - throw new ReleaseException("Release API does return with HTTP-%d - %s".formatted( - response.statusCode(), response.body())); - } - } catch (IOException | InterruptedException e) { - throw new ReleaseException(e); - } + public GitlabPublisher(String baseUrl, String token) { + super(baseUrl); + this.token = token; } - private GitlabRelease generatePayload(ReleaseInfo releaseInfo) { + @Override + protected Object generatePayload(ReleaseInfo releaseInfo) { return GitlabRelease.builder() .tagName(releaseInfo.tagName()) .name(releaseInfo.name()) @@ -76,6 +30,19 @@ private GitlabRelease generatePayload(ReleaseInfo releaseInfo) { .build(); } + @Override + protected String[] headers() { + return new String[]{ + "Content-Type", "application/json", + "PRIVATE-TOKEN", token + }; + } + + @Override + protected String getErrorMessage(String responseBody) { + return responseBody; + } + @Override public ProviderSpec providerSpec() { return new ProviderSpec( diff --git a/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/model/GitlabRelease.java b/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/model/GitlabRelease.java index 29cca09..c89676d 100644 --- a/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/model/GitlabRelease.java +++ b/semver-release-gitlab/src/main/java/io/github/sam42r/semver/release/model/GitlabRelease.java @@ -5,7 +5,6 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; -import lombok.NonNull; import java.time.LocalDateTime; import java.util.List; @@ -18,7 +17,6 @@ */ @Data @Builder -@NonNull @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class GitlabRelease { diff --git a/semver-release-gitlab/src/test/java/io/github/sam42r/semver/release/GitlabPublisherTest.java b/semver-release-gitlab/src/test/java/io/github/sam42r/semver/release/GitlabPublisherTest.java index 0161f2b..da13c44 100644 --- a/semver-release-gitlab/src/test/java/io/github/sam42r/semver/release/GitlabPublisherTest.java +++ b/semver-release-gitlab/src/test/java/io/github/sam42r/semver/release/GitlabPublisherTest.java @@ -6,14 +6,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; import org.mockserver.client.MockServerClient; import org.mockserver.junit.jupiter.MockServerExtension; import org.mockserver.matchers.Times; import org.mockserver.verify.VerificationTimes; +import java.io.IOException; +import java.net.http.HttpClient; import java.time.LocalDateTime; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; @@ -24,11 +32,7 @@ class GitlabPublisherTest { @BeforeEach void setup(MockServerClient client) { - uut = new GitlabPublisherFactory().getInstance( - "%s://%s/api/v4/projects/%s/releases", - null, - "token" - ); + uut = new GitlabPublisherFactory().getInstance(null, "token"); client.reset(); } @@ -69,7 +73,7 @@ void shouldCreateRelease(MockServerClient client) throws ReleaseException { } @Test - void shouldThrowOnHttp500(MockServerClient client) { + void shouldThrowOnHttpError(MockServerClient client) { client.when(request(), Times.exactly(1)) .respond(response() .withStatusCode(500) @@ -98,4 +102,38 @@ void shouldThrowOnHttp500(MockServerClient client) { .withHeader("PRIVATE-TOKEN", "token"), VerificationTimes.exactly(1)); } + + @ParameterizedTest + @MethodSource("thrownExceptions") + void shouldThrowOnException(Exception thrownException, String expectedMessage, MockServerClient client) throws IOException, InterruptedException { + try (var httpClientStatic = mockStatic(HttpClient.class)) { + var httpClient = mock(HttpClient.class); + when(httpClient.send(Mockito.any(), Mockito.any())).thenThrow(thrownException); + + httpClientStatic.when(HttpClient::newHttpClient).thenReturn(httpClient); + + var release = new ReleaseInfo( + "v1.0.0", + "v1.0.0", + "# Release v1.0.0", + LocalDateTime.now() + ); + + assertThatThrownBy(() -> uut.publish( + "http", + "localhost:%d".formatted(client.getPort()), + "JUnit", + "Test", + release)) + .isInstanceOf(ReleaseException.class) + .hasMessage(expectedMessage); + } + } + + private static Stream thrownExceptions() { + return Stream.of( + Arguments.of(new IOException("JUnit"), "java.io.IOException: JUnit"), + Arguments.of(new InterruptedException("JUnit"), "java.lang.InterruptedException: JUnit") + ); + } }