diff --git a/.travis.yml b/.travis.yml index e4a3a49..dba536b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,8 @@ addons: packages: - git language: groovy -jdk: oraclejdk7 +jdk: + - oraclejdk8 env: TERM=dumb cache: directories: diff --git a/README.md b/README.md index 07b3d67..979d1d1 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,12 @@ When `semantic-release` got setup it will do that after every successful continu This module ships with the [AngularJS Commit Message Conventions](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit) and changelog generator, but you can [define your own](#configuration) style. -> ### Commit Message Format +### Commit Message Format -> Each commit message consists of a **header**, a **body** and a **footer**. The header has a special +Each commit message consists of a **header**, a **body** and a **footer**. The header has a special format that includes a **type**, a **scope** and a **subject**: -> ``` +``` (): @@ -124,7 +124,7 @@ In order to automatically upload the changelog to GitHub, you need a [GitHub tok ```groovy project.ext.ghToken = project.hasProperty('ghToken') ? project.getProperty('ghToken') : System.getenv('GH_TOKEN') ?: null semanticRelease { - changeLog { + repo { ghToken = project.ghToken } } @@ -151,6 +151,19 @@ semanticRelease { } ``` +### Using with GitHub Enterprise + +By specifying `useGhEnterprise` this plugin can be used to publish releases along with the changelog to a GitHub Enterprise server. + +```groovy +semanticRelease { + repo { + ghToken = project.ext.ghToken + useGhEnterprise "https://github.enterprise" // GitHub Enterprise URL + } +} +``` + ### Setup travis-ci diff --git a/build.gradle b/build.gradle index 16d9b65..d196a82 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'de.gliderpilot.semantic-release' apply plugin: 'idea' +apply plugin: 'eclipse' apply plugin: 'com.github.ben-manes.versions' apply from: 'gradle/credentials.gradle' diff --git a/gradle/code-quality.gradle b/gradle/code-quality.gradle index 58c94be..31d1625 100644 --- a/gradle/code-quality.gradle +++ b/gradle/code-quality.gradle @@ -37,10 +37,10 @@ license { strictCheck = true ignoreFailures = true mapping { - java = 'SLASHSTAR_STYLE' + java = 'SLASHSTAR_STYLE' groovy = 'SLASHSTAR_STYLE' } - ext.year = '2015' + ext.year = '2017' } licenseTest { diff --git a/src/main/groovy/de/gliderpilot/gradle/semanticrelease/GhEnterpriseReleaseAssets.groovy b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/GhEnterpriseReleaseAssets.groovy new file mode 100644 index 0000000..6ba356d --- /dev/null +++ b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/GhEnterpriseReleaseAssets.groovy @@ -0,0 +1,91 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.gliderpilot.gradle.semanticrelease + +import javax.ws.rs.core.HttpHeaders + +import com.jcabi.github.Coordinates +import com.jcabi.github.Release +import com.jcabi.github.ReleaseAsset +import com.jcabi.github.ReleaseAssets +import com.jcabi.http.Request +import com.jcabi.http.response.JsonResponse +import com.jcabi.http.response.RestResponse + +/** Adapter class for GitHub Enterprise API endpoints + * + * RtReleaseAssets class is declared final. Therefore this class can not be extended and + * makes a wrapper/adapter class (like this) necessary. + * + * Contents of the methods of this class are modified versions of the RtReleaseAssets class + * (with support for GitHub Enterprise servers) + */ +public class GhEnterpriseReleaseAssets implements ReleaseAssets { + + private final Release owner + private final Request entry + + final URI githubUploadEndpoint + + public GhEnterpriseReleaseAssets(final String githubBaseUrl, Release owner, Request entry) { + this.githubUploadEndpoint = URI.create("${githubBaseUrl}/api/uploads") // base path for asset uploads + this.entry = entry + this.owner = owner + } + + @Override + public ReleaseAsset upload(final byte[] content, final String type, final String name) throws IOException { + return this.get( + getAssetUploadRequest(content, type, name) + .fetch().as(RestResponse.class) + .assertStatus(HttpURLConnection.HTTP_CREATED) + .as(JsonResponse.class) + .json().readObject().getInt("id") + ) + } + + Request getAssetUploadRequest(final byte[] content, final String type, final String name) { + return this.entry.uri() + .set(this.githubUploadEndpoint) + .path("/repos") + .path(this.owner.repo().coordinates().user()) + .path(this.owner.repo().coordinates().repo()) + .path("/releases") + .path(String.valueOf(this.owner.number())) + .path("/assets") + .queryParam("name", name) + .back() + .method(Request.POST) + .reset(HttpHeaders.CONTENT_TYPE) + .header(HttpHeaders.CONTENT_TYPE, type) + .body().set(content).back() + } + + @Override + public Release release() { + return owner.assets().release() + } + + @Override + public Iterable iterate() { + return owner.assets().iterate() + } + + @Override + public ReleaseAsset get(final int number) { + return owner.assets().get(number) + } +} diff --git a/src/main/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepo.groovy b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepo.groovy index 2983cef..f244ed6 100644 --- a/src/main/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepo.groovy +++ b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepo.groovy @@ -17,10 +17,14 @@ package de.gliderpilot.gradle.semanticrelease import com.jcabi.github.Github import com.jcabi.github.RtGithub +import com.jcabi.http.request.ApacheRequest +import com.jcabi.http.wire.AutoRedirectingWire import groovy.transform.Memoized import groovy.transform.PackageScope import org.ajoberstar.grgit.Grgit +import javax.ws.rs.core.HttpHeaders +import javax.ws.rs.core.MediaType import java.util.regex.Matcher class GithubRepo extends GitRepo { @@ -29,34 +33,89 @@ class GithubRepo extends GitRepo { private Github github + private String ghBaseUrl + + private boolean isGhEnterprise + + private String ghToken + @PackageScope Github getGithub() { github } GithubRepo(Grgit grgit) { + this.ghBaseUrl = "https://github.com" this.grgit = grgit + this.isGhEnterprise = false + } + + void setGhToken(String githubToken) { + this.ghToken = githubToken + this.github = buildGithubReference() } - void setGhToken(String token) { - if (token) - github = new RtGithub(token) + public String getGhBaseUrl() { + return this.ghBaseUrl + } + + public void useGhEnterprise(String ghEnterpriseUrl) { + this.ghBaseUrl = ghEnterpriseUrl.replaceAll("/+\$", "") // remove trailing slashes + this.isGhEnterprise = true + this.github = buildGithubReference() } @PackageScope @Memoized String getMnemo() { - String repositoryUrl = grgit.remote.list().find { it.name == 'origin' }.url - Matcher matcher = repositoryUrl =~ /.*github.com[\/:]((?:.+?)\/(?:.+?))(?:\.git)/ - if (!matcher) - return null - return matcher.group(1) + String repositoryUrl = grgit.getRemote().list().find { it.name == 'origin' }.url + + return getPathFromRepositoryUrl(repositoryUrl) + } + + /** Extracts the path of the repository. + * + * Will not check for the base path defined in ghBasePath + * + * @param repositoryUrl git remote url + * @return null when repository is not a github.com or GitHub Enterprise repository, otherwise path + */ + @PackageScope + String getPathFromRepositoryUrl(String repositoryUrl) { + // pathfinding logic extracted for better testability + boolean isGithubComRepository = (repositoryUrl ==~ /.*github.com[\/:]((?:.+?)\/(?:.+?))(?:\.git)/) + Matcher matcher = (repositoryUrl =~ /.+[\/:](.+?\/.+?)(?:\.git)$/) + + if (isGithubComRepository || this.isGhEnterprise) { + return matcher.group(1) + } + return null + } + + private RtGithub buildGithubReference() { + if (this.isGhEnterprise) { + // for github enterprise repositories + def request = new ApacheRequest("${ghBaseUrl}/api/v3") + .header(HttpHeaders.USER_AGENT, RtGithub.USER_AGENT) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + + if (this.ghToken != null) { // also add authentication token, if available + request = request.header(HttpHeaders.AUTHORIZATION, "token ${ghToken}") + } + + request = request.through(AutoRedirectingWire.class) + + return new RtGithub(request) + } else if (this.ghToken) { // for github.com repositories + return new RtGithub(this.ghToken) + } } private String repositoryUrl(String suffix) { if (!mnemo) return null - return "https://github.com/${mnemo}/$suffix" + return "${ghBaseUrl}/${mnemo}/$suffix" } String diffUrl(String previousTag, String currentTag) { @@ -71,4 +130,4 @@ class GithubRepo extends GitRepo { repositoryUrl("commit/${abbreviatedId}") } -} \ No newline at end of file +} diff --git a/src/main/groovy/de/gliderpilot/gradle/semanticrelease/SemanticReleaseChangeLogService.groovy b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/SemanticReleaseChangeLogService.groovy index c41ca38..8c4b077 100644 --- a/src/main/groovy/de/gliderpilot/gradle/semanticrelease/SemanticReleaseChangeLogService.groovy +++ b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/SemanticReleaseChangeLogService.groovy @@ -49,7 +49,7 @@ class SemanticReleaseChangeLogService { @Deprecated void setGhToken(String token) { logger.warn("semanticRelease.changeLog.ghToken is deprecated and will be removed in v2.0.0") - logger.warn("use semanticRelease.gitRepo.ghToken instead") + logger.warn("use semanticRelease.repo.ghToken instead") repo.ghToken = token } diff --git a/src/main/groovy/de/gliderpilot/gradle/semanticrelease/UpdateGithubReleaseService.groovy b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/UpdateGithubReleaseService.groovy index 702226b..fc29dec 100644 --- a/src/main/groovy/de/gliderpilot/gradle/semanticrelease/UpdateGithubReleaseService.groovy +++ b/src/main/groovy/de/gliderpilot/gradle/semanticrelease/UpdateGithubReleaseService.groovy @@ -15,15 +15,17 @@ */ package de.gliderpilot.gradle.semanticrelease +import org.ajoberstar.gradle.git.release.base.ReleaseVersion +import org.gradle.api.logging.Logger +import org.gradle.api.logging.Logging + import com.github.zafarkhaja.semver.Version import com.jcabi.github.Coordinates import com.jcabi.github.Release import com.jcabi.github.ReleaseAsset import com.jcabi.github.Repo + import groovy.transform.PackageScope -import org.ajoberstar.gradle.git.release.base.ReleaseVersion -import org.gradle.api.logging.Logger -import org.gradle.api.logging.Logging @PackageScope class UpdateGithubReleaseService { @@ -31,9 +33,9 @@ class UpdateGithubReleaseService { private final Logger logger = Logging.getLogger(getClass()) void updateGithubRelease(SemanticReleaseChangeLogService changeLog, - GithubRepo githubRepo, - ReleaseVersion version, - String tagName) { + GithubRepo githubRepo, + ReleaseVersion version, + String tagName) { Repo repo = githubRepo.github.repos().get(new Coordinates.Simple(githubRepo.mnemo)) // check for the existance of the tag using the api -> #3 @@ -43,12 +45,25 @@ class UpdateGithubReleaseService { Release release = repo.releases().create(tagName) def commits = changeLog.commits(Version.valueOf(version.previousVersion)) - new Release.Smart(release).body(changeLog.changeLog(commits, version).toString()) + + Release uploadGate = new Release.Smart(release) + uploadGate.body(changeLog.changeLog(commits, version).toString()) + + def releaseAssets + + if (githubRepo.isGhEnterprise) { // handle assets for GitHub Enterprise + releaseAssets = new GhEnterpriseReleaseAssets(githubRepo.getGhBaseUrl(), uploadGate, githubRepo.github.entry()) + } else { // handle assets for github.com + releaseAssets = uploadGate.assets() + } + githubRepo.releaseAssets.each { asset -> - ReleaseAsset releaseAsset = release.assets().upload(asset.file.bytes, asset.contentType, asset.name) - if (asset.label) + ReleaseAsset releaseAsset = releaseAssets.upload(asset.file.bytes, asset.contentType, asset.name) + if (asset.label) { new ReleaseAsset.Smart(releaseAsset).label(asset.label) + } } + } private boolean tagExists(Repo repo, String tag) { diff --git a/src/test/groovy/de/gliderpilot/gradle/semanticrelease/GhEnterpriseReleaseAssetsSpec.groovy b/src/test/groovy/de/gliderpilot/gradle/semanticrelease/GhEnterpriseReleaseAssetsSpec.groovy new file mode 100644 index 0000000..a05996f --- /dev/null +++ b/src/test/groovy/de/gliderpilot/gradle/semanticrelease/GhEnterpriseReleaseAssetsSpec.groovy @@ -0,0 +1,100 @@ +/* + * Copyright 2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.gliderpilot.gradle.semanticrelease + +import com.jcabi.github.Coordinates +import com.jcabi.github.Release +import com.jcabi.github.ReleaseAssets +import com.jcabi.github.Repo +import com.jcabi.http.Request +import com.jcabi.http.request.FakeRequest + +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Subject + +class GhEnterpriseReleaseAssetsSpec extends Specification { + @Shared + @Subject + GhEnterpriseReleaseAssets unit + + Request mockRequest + Release mockRelease + + def setup() { + def mockRepo = Mock(Repo.class) + mockRepo.coordinates() >> Mock(Coordinates.class) + mockRepo.coordinates().user() >> "test-user" + mockRepo.coordinates().repo() >> "test-repo" + + mockRequest = new FakeRequest( + HttpURLConnection.HTTP_CREATED, + "fake request", + Collections.>emptyList(), + "{ \"id\": 1337 }".bytes + ) + mockRelease = Mock() + + mockRelease.repo() >> mockRepo + mockRelease.number() >> 1337 + mockRelease.assets() >> Mock(ReleaseAssets.class) + + unit = new GhEnterpriseReleaseAssets("https://enterprise.github", mockRelease, mockRequest) + } + + def "should construct correct github upload api base url"() { + when: + unit = new GhEnterpriseReleaseAssets("https://enterprise.github", mockRelease, mockRequest) + + then: + unit.githubUploadEndpoint.toString() == "https://enterprise.github/api/uploads" + } + + def "should construct correct upload request with given data"() { + given: + def byte[] content = "This is the content" + + when: + def uploadRequest = unit.getAssetUploadRequest(content, "test-type", "test-name") + + then: + uploadRequest.toString().contains "test-user" + uploadRequest.toString().contains "test-repo" + uploadRequest.toString().contains "test-type" + uploadRequest.uri().toString().contains "test-name" + uploadRequest.body().get() == "This is the content" + } + + def "should send an asset upload request and get the asset id"() { + when: + unit.upload("test".bytes, "test-type", "test-name") + + then: + 1 * mockRelease.assets().get(1337) + } + + def "should pass method calls to underlying response class"() { + when: + unit.release() + unit.iterate() + unit.get(0) + + then: + 1 * mockRelease.assets().release() + 1 * mockRelease.assets().iterate() + 1 * mockRelease.assets().get(0) + } +} diff --git a/src/test/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepoSpec.groovy b/src/test/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepoSpec.groovy index dae405e..14a4384 100644 --- a/src/test/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepoSpec.groovy +++ b/src/test/groovy/de/gliderpilot/gradle/semanticrelease/GithubRepoSpec.groovy @@ -29,7 +29,11 @@ class GithubRepoSpec extends Specification { @Shared @Subject - GithubRepo repo = new GithubRepo(grgit) + GithubRepo repo + + def setup() { + repo = new GithubRepo(grgit) + } def "creates github service upon setting ghToken"() { when: @@ -37,6 +41,72 @@ class GithubRepoSpec extends Specification { then: repo.github instanceof RtGithub + repo.github.entry().uri().get().toString().contains("github.com") + } + + def "creates github enterprise service with api endpoint"() { + when: + repo.ghToken = '12345' + repo.useGhEnterprise 'https://github.enterprise/' + + then: + repo.github instanceof RtGithub + repo.github.entry().uri().get().toString().equals("https://github.enterprise/api/v3") + } + + def "creates github enterprise service with http authorization token upon setting ghToken and useGhEnterprise"() { + when: + repo.ghToken = '12345' + repo.useGhEnterprise 'https://github.enterprise/' + + then: + repo.github instanceof RtGithub + repo.github.entry().toString().contains("Authorization: token 12345") + } + + def "creates github enterprise service upon setting ghToken and useGhEnterprise"() { + when: + repo.useGhEnterprise 'https://github.enterprise/' + + then: + repo.github instanceof RtGithub + !repo.github.entry().toString().contains("Authorization: ") + } + + @Unroll + def "extract repository path for github.com and github enterprise from url"() { + when: + if (ghEnterprise) { + repo.useGhEnterprise 'https://github.enterprise/' + } + + then: + repo.getPathFromRepositoryUrl(url) == expectedMnemo + + where: + ghEnterprise | url | expectedMnemo + false | "http://github.com/org/repo.git" | "org/repo" + true | "https://anyurl/enterprise/repo.git" | "enterprise/repo" + false | "git@github.com:tschulte/gradle-semantic-release-plugin.git" | "tschulte/gradle-semantic-release-plugin" + true | "git@github.enterprise:tschulte/gradle-semantic-release-plugin.git" | "tschulte/gradle-semantic-release-plugin" + false | "git@github.enterprise:tschulte/gradle-semantic-release-plugin.git" | null + false | "https://github.enterprise:tschulte/gradle-semantic-release-plugin.git" | null + } + + @Unroll + def "diffUrl for #tag1 and #tag2 is #expectedUrl when setting ghBaseUrl"() { + when: + repo.useGhEnterprise 'https://github.enterprise/' + String diffUrl = repo.diffUrl(tag1, tag2) + + then: + diffUrl == expectedUrl + + where: + tag1 | tag2 | expectedUrl + "v1.0.0" | "v1.1.0" | "https://github.enterprise/${repo.mnemo}/compare/v1.0.0...v1.1.0" + "v1.0.0" | null | null + null | "v1.1.0" | null } @Unroll @@ -52,7 +122,6 @@ class GithubRepoSpec extends Specification { "v1.0.0" | "v1.1.0" | "https://github.com/${repo.mnemo}/compare/v1.0.0...v1.1.0" "v1.0.0" | null | null null | "v1.1.0" | null - } def "generates commitUrl"() { @@ -67,5 +136,4 @@ class GithubRepoSpec extends Specification { expect: repo.commitUrl(null) == null } - }