From b356d5ecc164c8a1f7209749a857399dac08d9a1 Mon Sep 17 00:00:00 2001 From: Szczepan Faber Date: Thu, 26 May 2022 11:35:27 -0500 Subject: [PATCH] Added support for updating releases This way, users can create releases from GH UI and the CI can still update the release notes. Fixes #97 --- README.md | 8 +- .../java/org/shipkit/changelog/GithubApi.java | 23 ++++- .../github/release/GithubReleaseTask.java | 52 +++++++++++- .../changelog/GithubReleaseTaskTest.groovy | 84 +++++++++++++++++++ version.properties | 2 +- 5 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 src/test/groovy/org/shipkit/changelog/GithubReleaseTaskTest.groovy diff --git a/README.md b/README.md index 04ea8b0..38d5f02 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Encourage and help software developers set up their releases to be fully automat # Shipkit Changelog Gradle plugin Our plugin generates changelog based on commit history and Github pull requests/issues. -Optionally, the changelog content can be posted to Github Releases. +Optionally, the changelog content can be posted to Github Releases +(as a new release or updating an existing release for a given tag). This plugin is very small (<1kloc) and has a single dependency "com.eclipsesource.minimal-json:minimal-json:0.9.5". The dependency is very small (30kb), stable (no changes since 2017), and brings zero transitive dependencies. @@ -182,7 +183,10 @@ Pick the best tool that work for you and start automating releases and changelog ### Posting Github releases -Uses Github REST API to post releases. +Uses Github REST API to post releases. +First, the code checks if the release _already exists_ for the given tag. +If it exists, the release notes are updated ([REST doc](https://docs.github.com/en/rest/releases/releases#update-a-release)). +If not, the new release is created ([REST doc](https://docs.github.com/en/rest/releases/releases#create-a-release)). ## Usage diff --git a/src/main/java/org/shipkit/changelog/GithubApi.java b/src/main/java/org/shipkit/changelog/GithubApi.java index b92ca1e..82f1918 100644 --- a/src/main/java/org/shipkit/changelog/GithubApi.java +++ b/src/main/java/org/shipkit/changelog/GithubApi.java @@ -31,6 +31,10 @@ public String post(String url, String body) throws IOException { return doRequest(url, "POST", Optional.of(body)).content; } + public String patch(String url, String body) throws IOException { + return doRequest(url, "PATCH", Optional.of(body)).content; + } + public Response get(String url) throws IOException { return doRequest(url, "GET", Optional.empty()); } @@ -39,9 +43,15 @@ private Response doRequest(String urlString, String method, Optional bod URL url = new URL(urlString); HttpsURLConnection c = (HttpsURLConnection) url.openConnection(); - c.setRequestMethod(method); + //workaround for Java limitation (https://bugs.openjdk.java.net/browse/JDK-7016595), works with GitHub REST API + if (method.equals("PATCH")) { + c.setRequestMethod("POST"); + } c.setDoOutput(true); c.setRequestProperty("Content-Type", "application/json"); + if (method.equals("PATCH")) { + c.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + } if (authToken != null) { c.setRequestProperty("Authorization", "token " + authToken); } @@ -83,7 +93,16 @@ private String call(String method, HttpsURLConnection conn) throws IOException { String errorMessage = String.format("%s %s failed, response code = %s, response body:%n%s", method, conn.getURL(), conn.getResponseCode(), IOUtil.readFully(conn.getErrorStream())); - throw new IOException(errorMessage); + throw new ResponseException(conn.getResponseCode(), errorMessage); + } + } + + public static class ResponseException extends IOException { + public final int responseCode; + + public ResponseException(int responseCode, String errorMessage) { + super(errorMessage); + this.responseCode = responseCode; } } diff --git a/src/main/java/org/shipkit/github/release/GithubReleaseTask.java b/src/main/java/org/shipkit/github/release/GithubReleaseTask.java index 2460894..bda33f9 100644 --- a/src/main/java/org/shipkit/github/release/GithubReleaseTask.java +++ b/src/main/java/org/shipkit/github/release/GithubReleaseTask.java @@ -2,6 +2,7 @@ import com.eclipsesource.json.Json; import com.eclipsesource.json.JsonObject; +import com.eclipsesource.json.JsonValue; import org.gradle.api.DefaultTask; import org.gradle.api.GradleException; import org.gradle.api.logging.Logger; @@ -14,6 +15,7 @@ import java.io.File; import java.io.IOException; +import java.util.Optional; public class GithubReleaseTask extends DefaultTask { @@ -142,9 +144,11 @@ public void setNewTagRevision(String newTagRevision) { body.add("body", releaseNotesTxt); GithubApi githubApi = new GithubApi(githubToken); + try { - String response = githubApi.post(url, body.toString()); - String htmlUrl = Json.parse(response).asObject().getString("html_url", ""); + LOG.lifecycle("Checking if release exists for tag {}...", releaseTag); + Optional existingRelease = existingRelease(githubApi, url, releaseTag); + final String htmlUrl = performRelease(existingRelease, githubApi, url, body.toString()); LOG.lifecycle("Posted release to Github: " + htmlUrl); } catch (IOException e) { throw new GradleException("Unable to post release to Github.\n" + @@ -159,4 +163,48 @@ public void setNewTagRevision(String newTagRevision) { , e); } } + + /** + * Updates an existing release or creates a new release. + * @param existingReleaseId if empty, new release will created. + * If it contains release ID (internal GH identifier) it will update that release + * @param githubApi the GH api object + * @param url the url to use + * @param body payload + * @return String with JSON contents + * @throws IOException when something goes wrong with REST call / HTTP connectivity + */ + String performRelease(Optional existingReleaseId, GithubApi githubApi, String url, String body) throws IOException { + final String htmlUrl; + if (existingReleaseId.isPresent()) { + LOG.lifecycle("Release already exists for tag {}! Updating the release notes...", releaseTag); + + String response = githubApi.patch(url + "/" + existingReleaseId.get(), body); + htmlUrl = Json.parse(response).asObject().getString("html_url", ""); + } else { + String response = githubApi.post(url, body); + htmlUrl = Json.parse(response).asObject().getString("html_url", ""); + } + return htmlUrl; + } + + /** + * Finds out if the release for given tag already exists + * + * @param githubApi api object + * @param url main REST url + * @param releaseTag the tag name, will be appended to the url + * @return existing release ID or empty optional if there is no release for the given tag + * @throws IOException when something goes wrong with REST call / HTTP connectivity + */ + Optional existingRelease(GithubApi githubApi, String url, String releaseTag) throws IOException { + try { + GithubApi.Response r = githubApi.get(url + "/tags/" + releaseTag); + JsonValue result = Json.parse(r.getContent()); + int releaseId = result.asObject().getInt("id", -1); + return Optional.of(releaseId); + } catch (GithubApi.ResponseException e) { + return Optional.empty(); + } + } } diff --git a/src/test/groovy/org/shipkit/changelog/GithubReleaseTaskTest.groovy b/src/test/groovy/org/shipkit/changelog/GithubReleaseTaskTest.groovy new file mode 100644 index 0000000..7aa3ea4 --- /dev/null +++ b/src/test/groovy/org/shipkit/changelog/GithubReleaseTaskTest.groovy @@ -0,0 +1,84 @@ +package org.shipkit.changelog + +import org.gradle.api.GradleException +import org.gradle.testfixtures.ProjectBuilder +import org.shipkit.github.release.GithubReleasePlugin +import org.shipkit.github.release.GithubReleaseTask +import spock.lang.Specification + +class GithubReleaseTaskTest extends Specification { + + def apiMock = Mock(GithubApi) + def project = ProjectBuilder.builder().build() + + def setup() { + project.plugins.apply(GithubReleasePlugin) + } + + def "knows if release already exists"() { + GithubReleaseTask task = project.tasks.githubRelease + apiMock.get("dummy/releases/tags/v1.2.3") >> new GithubApi.Response('{"id": 10}', '') + + when: + def result = task.existingRelease(apiMock, "dummy/releases", "v1.2.3") + + then: + result.get() == 10 + } + + def "knows if release does not yet exist"() { + GithubReleaseTask task = project.tasks.githubRelease + apiMock.get("dummy/releases/tags/v1.2.3") >> { throw new GithubApi.ResponseException(404, "") } + + when: + def result = task.existingRelease(apiMock, "dummy/releases", "v1.2.3") + + then: + !result.present + } + + def "creates new release"() { + GithubReleaseTask task = project.tasks.githubRelease + apiMock.post("dummy/url", "dummy body") >> '{"html_url": "dummy html url"}' + + when: + def result = task.performRelease(Optional.empty(), apiMock, "dummy/url", "dummy body") + + then: + result == "dummy html url" + } + + def "updates existing release"() { + GithubReleaseTask task = project.tasks.githubRelease + apiMock.patch("api/releases/123", "dummy body") >> '{"html_url": "dummy html url"}' + + when: + def result = task.performRelease(Optional.of(123), apiMock, "api/releases", "dummy body") + + then: + result == "dummy html url" + } + + /** + * Update githubToken and repo name for manual integration testing + */ + def "manual integration test"() { + project.version = "1.2.4" + project.file("changelog.md") << "Spanking new release! " + System.currentTimeSeconds() + project.tasks.named("githubRelease") { GithubReleaseTask it -> + it.changelog = project.file("changelog.md") + it.repository = "mockitoguy/shipkit-demo" //feel free to change to your private repo + it.newTagRevision = "aa51a6fe99d710c0e7ca30fc1d0411a8e9cdb7a8" //use sha of the repo above + it.githubToken = "secret" //update, use your token, DON'T CHECK IN + } + + when: + project.tasks.githubRelease.postRelease() + + then: + //when doing manual integration testing you won't get an exception here + //remove below / change the assertion when integ testing + thrown(GradleException) +// true + } +} diff --git a/version.properties b/version.properties index 12b9b2a..99ef383 100644 --- a/version.properties +++ b/version.properties @@ -1 +1 @@ -version=1.1.* +version=1.2.*