├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.gradle ├── gradle ├── integ-test.gradle ├── release.gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── src ├── integTest │ └── groovy │ │ └── org │ │ └── shipkit │ │ ├── BaseSpecification.groovy │ │ ├── changelog │ │ └── ChangelogPluginIntegTest.groovy │ │ └── gh │ │ └── release │ │ └── GithubReleasePluginIntegTest.groovy ├── main │ └── java │ │ └── org │ │ └── shipkit │ │ ├── changelog │ │ ├── ChangelogFormat.java │ │ ├── ChangelogPlugin.java │ │ ├── DateUtil.java │ │ ├── GenerateChangelogTask.java │ │ ├── GitCommit.java │ │ ├── GitCommitProvider.java │ │ ├── GitHubListFetcher.java │ │ ├── GitLogProvider.java │ │ ├── GithubApi.java │ │ ├── GithubImprovementsJSON.java │ │ ├── GithubTicketFetcher.java │ │ ├── IOUtil.java │ │ ├── ProcessRunner.java │ │ ├── Ticket.java │ │ └── TicketParser.java │ │ └── github │ │ └── release │ │ ├── GithubReleasePlugin.java │ │ └── GithubReleaseTask.java └── test │ └── groovy │ └── org │ └── shipkit │ └── changelog │ ├── ChangelogFormatTest.groovy │ ├── ChangelogPluginTest.groovy │ ├── DateUtilTest.groovy │ ├── GenerateChangelogTaskTest.groovy │ ├── GitCommitProviderTest.groovy │ ├── GitLogProviderTest.groovy │ ├── GithubImprovementsJSONTest.groovy │ ├── GithubReleaseTaskTest.groovy │ ├── GithubTicketFetcherIntegTest.groovy │ ├── GithubTicketFetcherTest.groovy │ ├── IOUtilTest.groovy │ ├── ProcessRunnerTest.groovy │ └── TicketParserTest.groovy └── version.properties /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # 2 | # CI build that also make relases from the main dev branch. 3 | # 4 | # - skipping CI: add [skip ci] to the commit message 5 | # - skipping release: add [skip release] to the commit message 6 | # 7 | name: CI 8 | 9 | on: 10 | push: 11 | branches: [master] 12 | tags-ignore: [v*] 13 | pull_request: 14 | branches: [master] 15 | 16 | jobs: 17 | windows_build: 18 | if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" 19 | runs-on: windows-latest 20 | steps: 21 | - uses: actions/checkout@v4.1.1 # docs: https://github.com/actions/checkout 22 | with: 23 | fetch-depth: '0' 24 | - name: Run build 25 | run: .\gradlew.bat build --continue 26 | 27 | build: 28 | 29 | runs-on: ubuntu-latest 30 | 31 | needs: windows_build 32 | 33 | steps: 34 | - uses: actions/checkout@v4.1.1 # docs: https://github.com/actions/checkout 35 | with: 36 | fetch-depth: '0' 37 | - name: Run build 38 | run: ./gradlew build --continue 39 | - name: Perform release (tagging, changelog, deployment to plugins.gradle.org) 40 | if: github.event_name == 'push' 41 | && github.ref == 'refs/heads/master' 42 | && github.repository == 'shipkit/shipkit-changelog' 43 | && !contains(toJSON(github.event.commits.*.message), '[skip release]') 44 | run: ./gradlew githubRelease publishPlugins 45 | env: 46 | # Gradle env variables docs: https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_environment_variables 47 | GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} 48 | GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | 4 | # Idea files / dirs 5 | .idea 6 | .shelf 7 | out 8 | *.iml 9 | *.ipr 10 | *.iws 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/shipkit/shipkit-changelog/workflows/CI/badge.svg)](https://github.com/shipkit/shipkit-changelog/actions) 2 | [![Gradle Plugin Portal](https://img.shields.io/maven-metadata/v/https/plugins.gradle.org/m2/org/shipkit/shipkit-changelog/maven-metadata.xml.svg?label=Version)](https://plugins.gradle.org/plugin/org.shipkit.shipkit-changelog) 3 | 4 |
5 | 6 | Shipkit Plugins 8 | 9 | 10 | ## Vision 11 | 12 | Software developers spend all their creative energy on productive work. 13 | There is absolutely **zero** release overhead because all software is released *automatically*. 14 | 15 | ## Mission 16 | 17 | Encourage and help software developers set up their releases to be fully automated. 18 | 19 | # Shipkit Changelog Gradle plugin 20 | 21 | Our plugin generates changelog based on commit history and Github pull requests/issues. 22 | Optionally, the changelog content can be posted to Github Releases 23 | (as a new release or updating an existing release for a given tag). 24 | This plugin is very small (<1kloc) and has a single dependency "com.eclipsesource.minimal-json:minimal-json:0.9.5". 25 | The dependency is very small (30kb), stable (no changes since 2017), and brings zero transitive dependencies. 26 | 27 | Example ([more examples](https://github.com/shipkit/shipkit-changelog/releases)): 28 | 29 | ---- 30 | #### 0.0.7 31 | - 2020-07-15 - [1 commit(s)](https://github.com/shipkit/shipkit-changelog/compare/v0.0.6...v0.0.7) by Szczepan Faber 32 | - Fixed broken links [(#12)](https://github.com/shipkit/shipkit-changelog/pull/12) 33 | ---- 34 | 35 | Also check out [shipkit-auto-version](https://github.com/shipkit/shipkit-auto-version) plugin that automatically picks the next version for the release. 36 | ```shipkit-auto-version``` and ```shipkit-changelog``` plugins work together perfectly. 37 | 38 | ## Basic usage 39 | 40 | Use the *highest* version available in the Gradle Plugin Portal: 41 | [![Gradle Plugin Portal](https://img.shields.io/maven-metadata/v/https/plugins.gradle.org/m2/org/shipkit/shipkit-changelog/maven-metadata.xml.svg?label=shipkit-changelog)](https://plugins.gradle.org/plugin/org.shipkit.shipkit-changelog) 42 | 43 | ```groovy 44 | plugins { 45 | id "org.shipkit.shipkit-changelog" version "x.y.z" 46 | } 47 | 48 | tasks.named("generateChangelog") { 49 | previousRevision = "v0.0.1" 50 | repository = "shipkit/shipkit-changelog" 51 | githubToken = System.getenv("GITHUB_TOKEN") // using env var to avoid checked-in secrets 52 | newTagRevision = System.getenv("GITHUB_SHA") // using an env var automatically exported by Github Actions 53 | } 54 | 55 | ``` 56 | 57 | ## Realistic example 58 | 59 | Realistic example, also uses a sibling plugin [shipkit-auto-version](https://github.com/shipkit/shipkit-auto-version) plugin 60 | at version: [![Gradle Plugin Portal](https://img.shields.io/maven-metadata/v/https/plugins.gradle.org/m2/org/shipkit/shipkit-auto-version/maven-metadata.xml.svg?label=shipkit-auto-version)](https://plugins.gradle.org/plugin/org.shipkit.shipkit-auto-version). 61 | Code sample source: [gradle/release.gradle](https://github.com/shipkit/shipkit-changelog/blob/master/gradle/release.gradle) 62 | 63 | ```groovy 64 | plugins { 65 | id 'org.shipkit.shipkit-changelog' version "x.y.z" 66 | id 'org.shipkit.shipkit-github-release' version "x.y.z" 67 | id 'org.shipkit.shipkit-auto-version' version "x.y.z" 68 | } 69 | 70 | tasks.named("generateChangelog") { 71 | previousRevision = project.ext.'shipkit-auto-version.previous-tag' 72 | githubToken = System.getenv("GITHUB_TOKEN") // using env var to avoid checked-in secrets 73 | repository = "shipkit/shipkit-changelog" 74 | } 75 | 76 | tasks.named("githubRelease") { 77 | dependsOn tasks.named("generateChangelog") 78 | repository = "shipkit/shipkit-changelog" 79 | changelog = tasks.named("generateChangelog").get().outputFile 80 | githubToken = System.getenv("GITHUB_TOKEN") // using env var to avoid checked-in secrets 81 | newTagRevision = System.getenv("GITHUB_SHA") // using an env var automatically exported by Github Actions 82 | } 83 | ``` 84 | 85 | ## Configuration reference 86 | 87 | ### Github access tokens 88 | 89 | The standard way to enable the automated tasks read/write to Github are the 90 | [personal access tokens](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token#creating-a-token). 91 | Shipkit Changelog plugin needs a token to fetch tickets and post releases via Github REST API. 92 | The token is set in the task configuration in \*.gradle file via `githubToken` property. 93 | When creating a new token using Github UI, make sure to select the `repo/public_repo` *scope* 94 | ([more info on scopes](https://docs.github.com/en/free-pro-team@latest/developers/apps/scopes-for-oauth-apps)). 95 | 96 | When using Github Actions then you can use the built-in `GITHUB_TOKEN` secret. 97 | You can pass it via an environment variable: 98 | 99 | ```yaml 100 | - name: Publish githubRelease 101 | run: ./gradlew githubRelease 102 | env: 103 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 104 | ``` 105 | 106 | Read more about Github Action's [GITHUB_TOKEN](https://docs.github.com/en/free-pro-team@latest/actions/reference/authentication-in-a-workflow). 107 | In Shipkit Changelog plugin the `githubToken` property should be supplied by the env variable to keep things safe. 108 | The token grants write access to the repository and it ***should not*** be exposed / checked-in. 109 | 110 | ### Fetch depth on CI 111 | 112 | CI systems are often configured by default to perform Git fetch with minimum amount of commits. 113 | However, our changelog plugin needs commits in order to generate the release notes. 114 | When using Github Actions, please configure your checkout action to fetch the entire history. 115 | Based on our tests in Mockito project, the checkout of the *entire* Mockito history (dating 2008) 116 | has negligible performance implication (adds ~2 secs to the checkout). 117 | 118 | ```yaml 119 | - uses: actions/checkout@v2 # docs: https://github.com/actions/checkout 120 | with: 121 | fetch-depth: '0' # will fetch the entire history 122 | ``` 123 | 124 | ### Target revision 125 | 126 | For proper release tagging the `newTagRevision` property needs to be set. 127 | This property is set with the SHA of the commit that will be tagged when Github release is created. 128 | Recommended way to do this is to use your CI system's built-in env variable that exposes the revision the CI is building. 129 | Github Actions expose `GITHUB_SHA` and hence we are using it in the code samples. 130 | You can read more about all [default env variables](https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables) exposed by Github Actions. 131 | Note that you can use *any* CI system, not necessarily Github Actions. 132 | Just refer to the documentation of your CI system to learn what are its default env variables. 133 | 134 | ### Tag name convention 135 | 136 | By default the plugin assumes "v" prefix notation for tags, for example: "v1.0.0". 137 | To use a different tag notation, such as "release-1.0.0" or "1.0.0" use `releaseTag` and `releaseName` properties on the tasks. 138 | See reference code examples. 139 | 140 | ## Customers / sample projects 141 | 142 | - https://github.com/shipkit/shipkit-demo (great example/reference project) 143 | - https://github.com/shipkit/shipkit-changelog (this project) 144 | - https://github.com/shipkit/shipkit-auto-version 145 | - https://github.com/mockito/mockito 146 | - https://github.com/mockito/mockito-scala 147 | - https://github.com/mockito/mockito-testng 148 | 149 | ## Other plugins/tools 150 | 151 | There are other Gradle plugins or tools that provide similar functionality: 152 | 153 | 1. [github-changelog-generator](https://github.com/github-changelog-generator/github-changelog-generator) 154 | is a popular Ruby Gem (6K stars on Github) that generates changelog based on commits/pull requests 155 | but does not publish Github releases ([#56](https://github.com/github-changelog-generator/github-changelog-generator/issues/56)). 156 | Our plugin is a pure Gradle solution and it can publish a Github release. 157 | 158 | 2. Github Action [Release Drafter](https://github.com/marketplace/actions/release-drafter) 159 | drafts the next release notes as pull requests are merged. 160 | This is a good option when the team wants to release on demand. 161 | Our plugin is great for fully automated releases on every merged pull request. 162 | 163 | 3. Gradle Plugin [git-changelog-gradle-plugin](https://github.com/tomasbjerre/git-changelog-gradle-plugin) 164 | seems like a nice plugin, maintained but not very popular (<50 stars on Github) and pulls in a lot of other dependencies 165 | ([#21](https://github.com/tomasbjerre/git-changelog-gradle-plugin/issues/21)). 166 | Our plugin is simpler, smaller and brings only one dependency (that is very small, simple and has no transitive dependencies). 167 | 168 | 4. Semantic Release [semantic-release](https://github.com/semantic-release/semantic-release) 169 | is a npm module for fully automated "semantic" releases, with changelog generation. 170 | It has impressive 10K stars on Github. 171 | Our plugin is less opinionated, smaller, simpler and a pure Gradle solution. 172 | 173 | Pick the best tool that work for you and start automating releases and changelog generation! 174 | 175 | ## Design 176 | 177 | ### Changelog generation 178 | 179 | 1. Collect commits between 2 revisions 180 | 2. Find ticket IDs based on '#' prefix in commit messages (e.g. looking for #1, #50, etc.) 181 | 3. Use Github REST API to collect ticket information (issue or pull request) from Github 182 | 4. Create markdown file using the PR/issue titles 183 | 184 | ### Posting Github releases 185 | 186 | Uses Github REST API to post releases. 187 | First, the code checks if the release _already exists_ for the given tag. 188 | If it exists, the release notes are updated ([REST doc](https://docs.github.com/en/rest/releases/releases#update-a-release)). 189 | If not, the new release is created ([REST doc](https://docs.github.com/en/rest/releases/releases#create-a-release)). 190 | 191 | ## Usage 192 | 193 | ### 'org.shipkit.shipkit-changelog' plugin 194 | 195 | Basic task configuration 196 | (source: [ChangelogPluginIntegTest](https://github.com/shipkit/shipkit-changelog/blob/master/src/integTest/groovy/org/shipkit/changelog/ChangelogPluginIntegTest.groovy)) 197 | 198 | ```groovy 199 | plugins { 200 | id 'org.shipkit.shipkit-changelog' 201 | } 202 | 203 | tasks.named("generateChangelog") { 204 | previousRevision = "v3.3.10" 205 | repository = "mockito/mockito" 206 | githubToken = System.getenv("GITHUB_TOKEN") // using env var to avoid checked-in secrets 207 | newTagRevision = System.getenv("GITHUB_SHA") // using an env var automatically exported by Github Actions 208 | } 209 | ``` 210 | 211 | Complete task configuration 212 | (source: [ChangelogPluginIntegTest](https://github.com/shipkit/shipkit-changelog/blob/master/src/integTest/groovy/org/shipkit/changelog/ChangelogPluginIntegTest.groovy)) 213 | 214 | ```groovy 215 | plugins { 216 | id 'org.shipkit.shipkit-changelog' 217 | } 218 | 219 | tasks.named("generateChangelog") { 220 | //file where the release notes are generated, default as below 221 | outputFile = new File(buildDir, "changelog.md") 222 | 223 | //Working directory for running 'git' commands, default as below 224 | workingDir = project.projectDir 225 | 226 | //Github url, configure if you use Github Enterprise, default as below 227 | githubUrl = "https://github.com" 228 | 229 | //Github API url, configure if you use Github Enterprise, default as below 230 | githubApiUrl = "https://api.github.com" 231 | 232 | //The release date, the default is today date 233 | date = "2020-06-06" 234 | 235 | //Previous revision to generate changelog, *no default* 236 | previousRevision = "v3.3.10" 237 | 238 | //Current revision to generate changelog, default as below 239 | revision = "HEAD" 240 | 241 | //The release version, default as below 242 | version = project.version 243 | 244 | //Release tag, by default it is "v" + project.version 245 | releaseTag = "v" + project.version 246 | 247 | //Repository to look for tickets, *no default* 248 | repository = "mockito/mockito" 249 | 250 | //Token used for fetching tickets, *empty* 251 | githubToken = System.getenv("GITHUB_TOKEN") // using env var to avoid checked-in secrets 252 | } 253 | ``` 254 | 255 | ### 'org.shipkit.shipkit-github-release' 256 | 257 | Basic task configuration 258 | (source: [GithubReleasePluginIntegTest](https://github.com/shipkit/shipkit-changelog/blob/master/src/integTest/groovy/org/shipkit/github/release/GithubReleasePluginIntegTest.groovy)) 259 | 260 | ```groovy 261 | plugins { 262 | id 'org.shipkit.shipkit-github-release' 263 | } 264 | 265 | tasks.named("githubRelease") { 266 | repository = "shipkit/shipkit-changelog" 267 | changelog = file("changelog.md") 268 | githubToken = System.getenv("GITHUB_TOKEN") // using env var to avoid checked-in secrets 269 | newTagRevision = System.getenv("GITHUB_SHA") // using an env var automatically exported by Github Actions 270 | } 271 | ``` 272 | 273 | Complete task configuration 274 | (source: [GithubReleasePluginIntegTest](https://github.com/shipkit/shipkit-changelog/blob/master/src/integTest/groovy/org/shipkit/gh/release/GithubReleasePluginIntegTest.groovy)) 275 | 276 | ```groovy 277 | plugins { 278 | id 'org.shipkit.shipkit-github-release' 279 | } 280 | 281 | tasks.named("githubRelease") { 282 | //Github API url, configure if you use Github Enterprise, default as below 283 | githubApiUrl = "https://api.github.com" 284 | 285 | //Repository where to create a release, *no default* 286 | repository = "shipkit/shipkit-changelog" 287 | 288 | //The file with changelog (release notes), *no default* 289 | changelog = file("changelog.md") 290 | 291 | //The name of the release, default as below 292 | releaseName = "v" + project.version 293 | 294 | //Github token used for posting to Github API, *no default* 295 | githubToken = System.getenv("GITHUB_TOKEN") // using env var to avoid checked-in secrets 296 | 297 | //SHA of the revision from which release is created; *no default* 298 | newTagRevision = System.getenv("GITHUB_SHA") // using an env var automatically exported by Github Actions 299 | 300 | //Release tag, by default it is "v" + project.version 301 | releaseTag = "v" + project.version 302 | } 303 | ``` 304 | 305 | # Contributing 306 | 307 | This project loves contributions! 308 | 309 | ## Testing 310 | 311 | In order to test the plugin behavior locally, first you need to *install* the plugin locally, 312 | and then use the *locally released* version in a selected *test project*. 313 | Example workflow: 314 | 315 | 1. Clone this repo, load the project into the IntelliJ IDEA, and make the code changes. 316 | 2. Run ```./gradlew publishToMavenLocal``` task to publish the new version locally. 317 | This version contains *your* changes implemented in the *previous* step. 318 | Observe the build output and note down the *version* that was published. 319 | We are using *maven local* because that's the easiest way to publish and consume a new version locally. 320 | 3. Select a test project where you want to observe/test changes implemented in the previous step. 321 | For example, you can use *your fork* of this repo as the test project. 322 | 4. Ensure that the test project is correctly *configured* in the Gradle build file. 323 | It needs to declare ```mavenLocal()``` as a *first* repository in ```buildscript.repositories``` 324 | ([code link](https://github.com/shipkit/shipkit-changelog/blob/master/build.gradle#L3), might be stale). 325 | Also, it needs to use the **correct** version of the plugin (the version that was published in the *earlier* step). 326 | Here's where the version is declared in the build file: [code link](https://github.com/shipkit/shipkit-changelog/blob/master/build.gradle#L8) 327 | (might be stale). 328 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenLocal() // for local testing 4 | maven { url "https://plugins.gradle.org/m2/" } 5 | } 6 | dependencies { 7 | classpath "org.shipkit:shipkit-auto-version:1.+" 8 | classpath "org.shipkit:shipkit-changelog:1.+" 9 | classpath "com.gradle.publish:plugin-publish-plugin:1.2.1" 10 | } 11 | } 12 | 13 | apply plugin: 'groovy' 14 | apply plugin: 'idea' 15 | apply plugin: 'maven-publish' 16 | apply plugin: 'java-gradle-plugin' 17 | 18 | apply plugin: 'com.gradle.plugin-publish' 19 | 20 | apply plugin: 'org.shipkit.shipkit-auto-version' 21 | apply plugin: 'org.shipkit.shipkit-changelog' 22 | apply plugin: 'org.shipkit.shipkit-github-release' 23 | 24 | group = "org.shipkit" 25 | 26 | sourceCompatibility = 1.8 27 | 28 | repositories { 29 | mavenCentral() 30 | } 31 | 32 | apply from: "$rootDir/gradle/integ-test.gradle" 33 | apply from: "$rootDir/gradle/release.gradle" 34 | 35 | dependencies { 36 | //small (30kb), stable (no changes since 2017), zero-dependencies library for JSON: 37 | implementation "com.eclipsesource.minimal-json:minimal-json:0.9.5" 38 | 39 | testImplementation 'junit:junit:4.13.2' 40 | 41 | testImplementation "org.spockframework:spock-core:2.3-groovy-3.0" 42 | testImplementation "cglib:cglib-nodep:3.3.0" //mocking concrete classes with spock 43 | testImplementation "org.objenesis:objenesis:3.3" //as above 44 | 45 | testImplementation gradleTestKit() 46 | } 47 | 48 | publishing { // docs: https://docs.gradle.org/current/userguide/publishing_maven.html 49 | publications { 50 | // Run this for local testing: "./gradlew publishMavenPublicationToMavenLocal" 51 | maven(MavenPublication) { 52 | from components.java 53 | } 54 | } 55 | } 56 | 57 | idea { 58 | module { 59 | excludeDirs += file(".shelf") 60 | } 61 | } -------------------------------------------------------------------------------- /gradle/integ-test.gradle: -------------------------------------------------------------------------------- 1 | sourceSets { 2 | integTest { 3 | } 4 | } 5 | 6 | gradlePlugin.testSourceSets(sourceSets.integTest) 7 | configurations.integTestImplementation.extendsFrom(configurations.testImplementation) 8 | 9 | task integTest(type: Test) { 10 | testClassesDirs = sourceSets.integTest.output.classesDirs 11 | classpath = sourceSets.integTest.runtimeClasspath 12 | } 13 | 14 | check { 15 | dependsOn(tasks.integTest) 16 | } 17 | 18 | idea { 19 | module { 20 | testSourceDirs += file('src/integTest/groovy') 21 | } 22 | } -------------------------------------------------------------------------------- /gradle/release.gradle: -------------------------------------------------------------------------------- 1 | // docs: https://plugins.gradle.org/docs/publish-plugin 2 | gradlePlugin { 3 | website = 'https://github.com/shipkit/shipkit-changelog' 4 | vcsUrl = 'https://github.com/shipkit/shipkit-changelog.git' 5 | plugins { 6 | changelog { 7 | id = 'org.shipkit.shipkit-changelog' 8 | implementationClass = 'org.shipkit.changelog.ChangelogPlugin' 9 | displayName = 'Shipkit changelog plugin' 10 | description = 'Generates changelog based on ticked IDs found in commit messages and Github pull request information' 11 | tags.addAll('ci', 'shipkit', 'changelog') 12 | } 13 | githubRelease { 14 | id = 'org.shipkit.shipkit-github-release' 15 | implementationClass = 'org.shipkit.github.release.GithubReleasePlugin' 16 | displayName = 'Shipkit Github release plugin' 17 | description = 'Posts a release to Github using the REST API' 18 | tags.addAll('ci', 'shipkit', 'github', 'release') 19 | } 20 | } 21 | } 22 | 23 | ext.'gradle.publish.key' = System.getenv('GRADLE_PUBLISH_KEY') 24 | ext.'gradle.publish.secret' = System.getenv('GRADLE_PUBLISH_SECRET') 25 | 26 | if (ext.'gradle.publish.key' && ext.'gradle.publish.secret') { 27 | println "Gradle Plugin Portal environment variables: " + 28 | "key=${ext.'gradle.publish.key'.substring(0, 3)}, secret=${ext.'gradle.publish.secret'.substring(0, 3)}" 29 | } 30 | 31 | tasks.named("generateChangelog") { 32 | previousRevision = project.ext.'shipkit-auto-version.previous-tag' 33 | githubToken = System.getenv("GITHUB_TOKEN") 34 | repository = "shipkit/shipkit-changelog" 35 | } 36 | 37 | tasks.named("githubRelease") { 38 | dependsOn tasks.named("generateChangelog") 39 | repository = "shipkit/shipkit-changelog" 40 | changelog = tasks.named("generateChangelog").get().outputFile 41 | newTagRevision = System.getenv("GITHUB_SHA") 42 | githubToken = System.getenv("GITHUB_TOKEN") 43 | } 44 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipkit/shipkit-changelog/43ec4cbc5f317c19f3fd1534ec8bba12ea5ff7da/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | # Collect all arguments for the java command; 201 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 202 | # shell script including quotes and variable substitutions, so put them in 203 | # double quotes to make sure that they get re-expanded; and 204 | # * put everything else in single quotes, so that it's not re-expanded. 205 | 206 | set -- \ 207 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 208 | -classpath "$CLASSPATH" \ 209 | org.gradle.wrapper.GradleWrapperMain \ 210 | "$@" 211 | 212 | # Stop when "xargs" is not available. 213 | if ! command -v xargs >/dev/null 2>&1 214 | then 215 | die "xargs is not available" 216 | fi 217 | 218 | # Use "xargs" to parse quoted args. 219 | # 220 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 221 | # 222 | # In Bash we could simply go: 223 | # 224 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 225 | # set -- "${ARGS[@]}" "$@" 226 | # 227 | # but POSIX shell has neither arrays nor command substitution, so instead we 228 | # post-process each arg (as a line of input to sed) to backslash-escape any 229 | # character that might be a shell metacharacter, then use eval to reverse 230 | # that process (while maintaining the separation between arguments), and wrap 231 | # the whole thing up as a single "set" statement. 232 | # 233 | # This will of course break if any of these variables contains a newline or 234 | # an unmatched quote. 235 | # 236 | 237 | eval "set -- $( 238 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 239 | xargs -n1 | 240 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 241 | tr '\n' ' ' 242 | )" '"$@"' 243 | 244 | exec "$JAVACMD" "$@" 245 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.gradle.enterprise" version "3.16.1" 3 | } 4 | 5 | rootProject.name = "shipkit-changelog" 6 | 7 | gradleEnterprise { 8 | buildScan { 9 | termsOfServiceUrl = "https://gradle.com/terms-of-service" 10 | termsOfServiceAgree = "yes" 11 | if (System.getenv("CI")) { 12 | publishAlways() 13 | uploadInBackground = false 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/integTest/groovy/org/shipkit/BaseSpecification.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit 2 | 3 | import org.gradle.testkit.runner.GradleRunner 4 | import spock.lang.Specification 5 | 6 | class BaseSpecification extends Specification { 7 | 8 | File file(String path) { 9 | def f = new File(rootDir, path) 10 | if (!f.exists()) { 11 | f.parentFile.mkdirs() 12 | f.createNewFile() 13 | assert f.exists() 14 | } 15 | return f 16 | } 17 | 18 | File getRootDir() { 19 | return tmp.root 20 | } 21 | 22 | GradleRunner runner(String... args) { 23 | def runner = GradleRunner.create() 24 | runner.forwardOutput() 25 | runner.withPluginClasspath() 26 | runner.withArguments(args) 27 | runner.withProjectDir(rootDir) 28 | runner 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/integTest/groovy/org/shipkit/changelog/ChangelogPluginIntegTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import org.junit.Rule 4 | import org.junit.rules.TemporaryFolder 5 | import org.shipkit.BaseSpecification 6 | 7 | /** 8 | * Only smoke test, forever! Don't add more tests here, instead cover the complexity in lower level unit tests. 9 | */ 10 | class ChangelogPluginIntegTest extends BaseSpecification { 11 | 12 | @Rule TemporaryFolder tmp = new TemporaryFolder() 13 | 14 | def "basic task configuration with no previous version"() { 15 | file("build.gradle") << """ 16 | plugins { 17 | id 'org.shipkit.shipkit-changelog' 18 | } 19 | 20 | version = "1.2.3" 21 | 22 | tasks.named("generateChangelog") { 23 | githubToken = "secret" 24 | repository = "org/repo" 25 | date = "2022-01-01" // for reproducible assertion 26 | } 27 | """ 28 | 29 | when: 30 | runner("generateChangelog").build() 31 | 32 | then: 33 | //since this is an edge case (no previous versions/tags) we're ok with oversimplified output with bad links 34 | file("build/changelog.md").text == """*Changelog generated by [Shipkit Changelog Gradle Plugin](https://github.com/shipkit/shipkit-changelog)* 35 | 36 | #### 1.2.3 37 | - 2022-01-01 - [0 commit(s)](https://github.com/org/repo/compare/@previousRev@...v1.2.3) by 38 | - No notable improvements. No pull requests (issues) were referenced from commits.""" 39 | } 40 | 41 | def "complete task configuration"() { 42 | file("build.gradle") << """ 43 | plugins { 44 | id 'org.shipkit.shipkit-changelog' 45 | } 46 | 47 | tasks.named("generateChangelog") { 48 | //file where the release notes are generated, default as below 49 | outputFile = new File(buildDir, "changelog.md") 50 | 51 | //Working directory for running 'git' commands, default as below 52 | workingDir = project.projectDir 53 | 54 | //Github url, configure if you use Github Enterprise, default as below 55 | githubUrl = "https://github.com" 56 | 57 | //Github API url, configure if you use Github Enterprise, default as below 58 | githubApiUrl = "https://api.github.com" 59 | 60 | //The release date, the default is today date 61 | date = "2020-06-06" 62 | 63 | //Previous revision to generate changelog, *no default* 64 | previousRevision = "v3.3.10" 65 | 66 | //Current revision to generate changelog, default as below 67 | revision = "HEAD" 68 | 69 | //The release version, default as below 70 | version = project.version 71 | 72 | //Release tag, by default it is "v" + project.version 73 | releaseTag = "v" + project.version 74 | 75 | //Token that enables querying Github, safe to check-in because it is read-only, *no default* 76 | githubToken = "a0a4c0f41c200f7c653323014d6a72a127764e17" 77 | 78 | //Repository to look for tickets, *no default* 79 | repository = "org/repo" 80 | } 81 | """ 82 | 83 | expect: "run in dry-run mode to smoke test the configuration" 84 | runner("generateChangelog", "-m").build() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/integTest/groovy/org/shipkit/gh/release/GithubReleasePluginIntegTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.github.release 2 | 3 | 4 | import org.junit.Rule 5 | import org.junit.rules.TemporaryFolder 6 | import org.shipkit.BaseSpecification 7 | 8 | /** 9 | * Smoke test, we don't want an integration test that actually posts to Github 10 | */ 11 | class GithubReleasePluginIntegTest extends BaseSpecification { 12 | 13 | @Rule TemporaryFolder tmp = new TemporaryFolder() 14 | 15 | def setup() { 16 | file("settings.gradle") 17 | } 18 | 19 | def "basic task configuration"() { 20 | file("build.gradle") << """ 21 | plugins { 22 | id 'org.shipkit.shipkit-github-release' 23 | } 24 | 25 | tasks.named("githubRelease") { 26 | repository = "shipkit/shipkit-changelog" 27 | changelog = file("changelog.md") 28 | newTagRevision = "ff2fb22b3bb2fb08164c126c0e2055d57dee441b" 29 | githubToken = "secret" 30 | } 31 | """ 32 | 33 | expect: 34 | runner("githubRelease", "-m").build() 35 | } 36 | 37 | def "complete task configuration"() { 38 | file("build.gradle") << """ 39 | plugins { 40 | id 'org.shipkit.shipkit-github-release' 41 | } 42 | 43 | tasks.named("githubRelease") { 44 | //Github API url, configure if you use Github Enterprise, default as below 45 | githubApiUrl = "https://api.github.com" 46 | 47 | //Repository where to create a release, *no default* 48 | repository = "shipkit/shipkit-changelog" 49 | 50 | //The file with changelog (release notes), *no default* 51 | changelog = file("changelog.md") 52 | 53 | //The name of the release, default as below 54 | releaseName = "v" + project.version 55 | 56 | //SHA of the revision from which release is created; *no default* 57 | newTagRevision = "ff2fb22b3bb2fb08164c126c0e2055d57dee441b" 58 | 59 | //Release tag, by default it is "v" + project.version 60 | releaseTag = "v" + project.version 61 | 62 | //Github token used for posting to Github API, *no default* 63 | githubToken = "secret" 64 | } 65 | """ 66 | 67 | expect: 68 | runner("githubRelease", "-m").build() 69 | } 70 | 71 | def "fails with clean exception"() { 72 | file("build.gradle") << """ 73 | plugins { id 'org.shipkit.shipkit-github-release' } 74 | version = "1.2.3" 75 | file("changelog.md") << "Spanking new release!" 76 | tasks.named("githubRelease") { 77 | repository = "shipkit/shipkit-changelog" 78 | changelog = file("changelog.md") 79 | newTagRevision = "ff2fb22b3bb2fb08164c126c0e2055d57dee441b" 80 | githubToken = "secret" 81 | } 82 | """ 83 | 84 | when: 85 | def result = runner("githubRelease", "-s").buildAndFail() 86 | 87 | then: //fails because we don't have the credentials 88 | result.output.contains """Unable to post release to Github. 89 | * url: https://api.github.com/repos/shipkit/shipkit-changelog/releases 90 | * release tag: v1.2.3 91 | * release name: v1.2.3 92 | * token: sec... 93 | * content: 94 | Spanking new release! 95 | 96 | * underlying problem:""" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/ChangelogFormat.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import java.util.Collection; 4 | import java.util.HashMap; 5 | import java.util.Map; 6 | import java.util.regex.Matcher; 7 | import java.util.regex.Pattern; 8 | import java.util.stream.Collectors; 9 | 10 | /** 11 | * Formats the changelog content. 12 | */ 13 | public class ChangelogFormat { 14 | 15 | /** 16 | * Builds the changelog String based on the input parameters. 17 | */ 18 | public static String formatChangelog(Collection contributors, Collection tickets, int commitCount, 19 | final String releaseTag, final String version, final String previousRev, 20 | final String githubRepoUrl, final String date) { 21 | String template = "@header@\n" + 22 | "\n" + 23 | "#### @version@\n" + 24 | " - @date@ - [@commitCount@ commit(s)](@repoUrl@/compare/@previousRev@...@newRev@) by @contributors@\n" + 25 | "@improvements@"; 26 | 27 | Map data = new HashMap() {{ 28 | put("header", "*Changelog generated by [Shipkit Changelog Gradle Plugin](https://github.com/shipkit/shipkit-changelog)*"); 29 | put("version", version); 30 | put("date", date); 31 | put("commitCount", "" + commitCount); 32 | put("repoUrl", githubRepoUrl); 33 | put("previousRev", previousRev); 34 | put("newRev", releaseTag); 35 | put("contributors", String.join(", ", contributors)); 36 | put("improvements", formatImprovements(tickets)); 37 | }}; 38 | 39 | return replaceTokens(template, data); 40 | } 41 | 42 | private static String formatImprovements(Collection tickets) { 43 | if (tickets.isEmpty()) { 44 | return " - No notable improvements. No pull requests (issues) were referenced from commits."; 45 | } 46 | return String.join("\n", tickets.stream().map(i -> " - " + i.getTitle() + 47 | " [(#" + i.getId() + ")](" + 48 | i.getUrl() + ")").collect(Collectors.toList())); 49 | } 50 | 51 | private static String replaceTokens(String text, 52 | Map replacements) { 53 | Pattern pattern = Pattern.compile("@(.+?)@"); 54 | Matcher matcher = pattern.matcher(text); 55 | StringBuffer buffer = new StringBuffer(); 56 | 57 | while (matcher.find()) { 58 | String replacement = replacements.get(matcher.group(1)); 59 | if (replacement != null) { 60 | matcher.appendReplacement(buffer, ""); 61 | buffer.append(replacement); 62 | } 63 | } 64 | matcher.appendTail(buffer); 65 | return buffer.toString(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/ChangelogPlugin.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import org.gradle.api.Plugin; 4 | import org.gradle.api.Project; 5 | import org.gradle.api.specs.Specs; 6 | 7 | import java.io.File; 8 | import java.util.Date; 9 | 10 | /** 11 | * The plugin, ideally with zero business logic, but only the Gradle integration code 12 | */ 13 | public class ChangelogPlugin implements Plugin { 14 | 15 | public void apply(Project project) { 16 | project.getTasks().register("generateChangelog", GenerateChangelogTask.class, t -> { 17 | t.setRevision("HEAD"); 18 | t.setDate(DateUtil.formatDate(new Date())); 19 | t.setOutputFile(new File(project.getBuildDir(), "changelog.md")); 20 | t.setGithubApiUrl("https://api.github.com"); 21 | t.setGithubUrl("https://github.com"); 22 | t.setWorkingDir(project.getProjectDir()); 23 | t.setVersion("" + project.getVersion()); 24 | t.setReleaseTag("v" + project.getVersion()); 25 | t.getOutputs().upToDateWhen(Specs.satisfyNone()); //depends on state of Git repo, Github, etc. 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/DateUtil.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import java.text.SimpleDateFormat; 4 | import java.util.Date; 5 | import java.util.TimeZone; 6 | 7 | /** 8 | * Date and Time utilities 9 | */ 10 | class DateUtil { 11 | 12 | /** 13 | * Formats date to most reasonable format to show on the release notes 14 | */ 15 | static String formatDate(Date date) { 16 | SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd"); 17 | f.setTimeZone(TimeZone.getTimeZone("UTC")); 18 | return f.format(date); 19 | } 20 | 21 | /** 22 | * Parse Date in epoch seconds (Unix time). 23 | */ 24 | static Date parseDateInEpochSeconds(String date) { 25 | return new Date(Long.parseLong(date) * 1000); 26 | } 27 | 28 | /** 29 | * Formats date to local timezone to shows in debug logs 30 | */ 31 | static String formatDateToLocalTime(Date date, TimeZone tz) { 32 | SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd hh:mm a z"); 33 | f.setTimeZone(tz); 34 | return f.format(date); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GenerateChangelogTask.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import org.gradle.api.DefaultTask; 4 | import org.gradle.api.logging.Logger; 5 | import org.gradle.api.logging.Logging; 6 | import org.gradle.api.tasks.Optional; 7 | import org.gradle.api.tasks.*; 8 | import org.shipkit.github.release.GithubReleaseTask; 9 | 10 | import java.io.File; 11 | import java.util.*; 12 | 13 | /** 14 | * Generates changelog based on the Github ticked ids found in commit messages. 15 | */ 16 | public class GenerateChangelogTask extends DefaultTask { 17 | 18 | private final static Logger LOG = Logging.getLogger(GenerateChangelogTask.class); 19 | 20 | private String githubUrl; 21 | private File outputFile; 22 | private File workingDir; 23 | private String githubToken; 24 | private String githubApiUrl; 25 | private String repository; 26 | private String previousRevision; 27 | private String version; 28 | private String releaseTag; 29 | private String revision; 30 | private String date; 31 | 32 | /** 33 | * The release date 34 | */ 35 | @Input 36 | public String getDate() { 37 | return date; 38 | } 39 | 40 | public void setDate(String date) { 41 | this.date = date; 42 | } 43 | 44 | @Input 45 | public String getGithubUrl() { 46 | return githubUrl; 47 | } 48 | 49 | public void setGithubUrl(String githubUrl) { 50 | this.githubUrl = githubUrl; 51 | } 52 | 53 | /** 54 | * Previous revision for changelog generation. 55 | * The changelog is generated between {@code #getPreviousRevision()} and {@link #getRevision()}. 56 | * 57 | * This property is marked as {@code Optional} because the {@code null} value is permitted. 58 | * In this case the task will use "HEAD" as previous revision. 59 | * This way, the task behaves gracefully when generating changelog for the first time (very first version). 60 | */ 61 | @Input 62 | @Optional 63 | public String getPreviousRevision() { 64 | return previousRevision; 65 | } 66 | 67 | /** 68 | * See {@link #getPreviousRevision()} 69 | */ 70 | public void setPreviousRevision(String previousRevision) { 71 | this.previousRevision = previousRevision; 72 | } 73 | 74 | @Input 75 | public String getVersion() { 76 | return version; 77 | } 78 | 79 | public void setVersion(String version) { 80 | this.version = version; 81 | } 82 | 83 | /** 84 | * Release tag, for example "v1.2.3". 85 | * It is used to construct a GitHub link to a diff between previous revision and the new release tag. 86 | */ 87 | @Input 88 | public String getReleaseTag() { 89 | return releaseTag; 90 | } 91 | 92 | /** 93 | * See {@link #getReleaseTag()} 94 | */ 95 | public void setReleaseTag(String releaseTag) { 96 | this.releaseTag = releaseTag; 97 | } 98 | 99 | /** 100 | * Target revision for changelog generation. 101 | * The changelog is generated between {@link #getPreviousRevision()} and {@code #getRevision()}. 102 | */ 103 | @Input 104 | public String getRevision() { 105 | return revision; 106 | } 107 | 108 | /** 109 | * See {@link #getRevision()} 110 | */ 111 | public void setRevision(String revision) { 112 | this.revision = revision; 113 | } 114 | 115 | @Input 116 | public String getRepository() { 117 | return repository; 118 | } 119 | 120 | public void setRepository(String repository) { 121 | this.repository = repository; 122 | } 123 | 124 | @Input 125 | public String getGithubApiUrl() { 126 | return githubApiUrl; 127 | } 128 | 129 | public void setGithubApiUrl(String githubApiUrl) { 130 | this.githubApiUrl = githubApiUrl; 131 | } 132 | 133 | @OutputFile 134 | public File getOutputFile() { 135 | return outputFile; 136 | } 137 | 138 | public void setOutputFile(File outputFile) { 139 | this.outputFile = outputFile; 140 | } 141 | 142 | @Internal 143 | public File getWorkingDir() { 144 | return workingDir; 145 | } 146 | 147 | @Optional 148 | @InputDirectory 149 | @PathSensitive(PathSensitivity.RELATIVE) 150 | public File getGitDir() { 151 | return provideGitDir(workingDir); 152 | } 153 | 154 | static File provideGitDir(File workingDir) { 155 | if (workingDir == null) { 156 | return null; 157 | } 158 | 159 | File gitDir = new File(workingDir, ".git"); 160 | return gitDir.isDirectory() ? gitDir : null; 161 | } 162 | 163 | public void setWorkingDir(File workingDir) { 164 | this.workingDir = workingDir; 165 | } 166 | 167 | /** 168 | * Deprecated, please use {@link #getGithubToken()} 169 | */ 170 | @Input 171 | @Optional 172 | @Deprecated 173 | public String getReadOnlyToken() { 174 | return getGithubToken(); 175 | } 176 | 177 | /** 178 | * Deprecated, please use {@link #setGithubToken(String)} 179 | */ 180 | @Deprecated 181 | public void setReadOnlyToken(String readOnlyToken) { 182 | this.setGithubToken(readOnlyToken); 183 | } 184 | 185 | /** 186 | * See {@link #setGithubToken(String)} 187 | */ 188 | @Input 189 | @Optional 190 | public String getGithubToken() { 191 | return githubToken; 192 | } 193 | 194 | /** 195 | * Github token used to pull Github issues. 196 | * The same token is used to post a new release: 197 | * {@link GithubReleaseTask#setGithubToken(String)} 198 | */ 199 | public void setGithubToken(String githubToken) { 200 | this.githubToken = githubToken; 201 | } 202 | 203 | @TaskAction public void generateChangelog() { 204 | ProcessRunner runner = new ProcessRunner(workingDir); 205 | GitLogProvider logProvider = new GitLogProvider(runner); 206 | 207 | Collection commits = new LinkedList<>(); 208 | Collection improvements = new LinkedList<>(); 209 | Set contributors = new TreeSet<>(); 210 | 211 | if (previousRevision != null) { 212 | LOG.lifecycle("Finding commits between {}..{} in dir: {}", previousRevision, revision, workingDir); 213 | commits = new GitCommitProvider(logProvider).getCommits(previousRevision, revision); 214 | 215 | LOG.lifecycle("Collecting ticket ids from {} commits.", commits.size()); 216 | List ticketIds = new LinkedList<>(); 217 | for (GitCommit c : commits) { 218 | ticketIds.addAll(c.getTickets()); 219 | contributors.add(c.getAuthor()); 220 | } 221 | 222 | LOG.lifecycle("Fetching ticket info from {}/{} based on {} ids {}", githubApiUrl, repository, ticketIds.size(), ticketIds); 223 | 224 | GithubTicketFetcher fetcher = new GithubTicketFetcher(githubApiUrl, repository, githubToken); 225 | improvements = fetcher.fetchTickets(ticketIds); 226 | } 227 | 228 | LOG.lifecycle("Generating changelog based on {} tickets from Github", improvements.size()); 229 | String changelog = ChangelogFormat.formatChangelog(contributors, improvements, commits.size(), releaseTag, version, 230 | previousRevision, githubUrl + "/" + repository, date); 231 | 232 | LOG.lifecycle("Saving changelog to file: {}", outputFile); 233 | IOUtil.writeFile(outputFile, changelog.trim()); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GitCommit.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import java.util.Collection; 4 | import java.util.Set; 5 | 6 | class GitCommit { 7 | 8 | private final String author; 9 | private final Set tickets; 10 | 11 | GitCommit(String author, String message) { 12 | this.author = author; 13 | this.tickets = TicketParser.parseTickets(message); 14 | } 15 | 16 | public String getAuthor() { 17 | return author; 18 | } 19 | 20 | public Collection getTickets() { 21 | return tickets; 22 | } 23 | 24 | @Override 25 | public String toString() { 26 | return '{' + 27 | "author='" + getAuthor() + '\'' + 28 | ", tickets=" + getTickets() + 29 | '}'; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GitCommitProvider.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import java.util.Collection; 4 | import java.util.LinkedList; 5 | import java.util.logging.Logger; 6 | 7 | class GitCommitProvider { 8 | 9 | private static final Logger LOG = Logger.getLogger(GitCommitProvider.class.getName()); 10 | private final GitLogProvider logProvider; 11 | 12 | GitCommitProvider(GitLogProvider logProvider) { 13 | this.logProvider = logProvider; 14 | } 15 | 16 | Collection getCommits(String fromRev, String toRev) { 17 | LOG.info("Loading all commits between " + fromRev + " and " + toRev); 18 | 19 | LinkedList commits = new LinkedList<>(); 20 | String commitToken = "@@commit@@"; 21 | String infoToken = "@@info@@"; 22 | // %H: commit hash 23 | // %ae: author email 24 | // %an: author name 25 | // %B: raw body (unwrapped subject and body) 26 | // %N: commit notes 27 | String log = logProvider.getLog(fromRev, toRev, "--pretty=format:%H" + infoToken + "%ae" + infoToken + "%an" + infoToken + "%B%N" + commitToken); 28 | 29 | for (String entry : log.split(commitToken)) { 30 | String[] entryParts = entry.split(infoToken); 31 | if (entryParts.length == 4) { 32 | String author = entryParts[2].trim(); 33 | String message = entryParts[3].trim(); 34 | commits.add(new GitCommit(author, message)); 35 | } 36 | } 37 | return commits; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GitHubListFetcher.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import com.eclipsesource.json.Json; 4 | import com.eclipsesource.json.JsonArray; 5 | import com.eclipsesource.json.JsonValue; 6 | 7 | import java.io.IOException; 8 | 9 | /** 10 | * This class contains standard operations for skim over Github API responses. 11 | */ 12 | class GithubListFetcher { 13 | 14 | private static final String NO_MORE_PAGES = "none"; 15 | private final String githubToken; 16 | private String nextPageUrl; 17 | 18 | GithubListFetcher(String apiUrl, String repository, String githubToken) { 19 | this.githubToken = githubToken; 20 | 21 | // see API doc: https://developer.github.com/v3/issues/ 22 | nextPageUrl = apiUrl + "/repos/" + repository + "/issues?page=1" 23 | + "&per_page=100" //default page is 30 24 | + "&state=closed" //default state is open 25 | + "&filter=all" //default filter is 'assigned' 26 | + "&direction=desc"; //default is desc but setting it explicitly just in case 27 | } 28 | 29 | /** 30 | * Returns true when 'nextPage()' was not yet executed 31 | * OR when 'nextPage()' was executed and there are more pages 32 | */ 33 | boolean hasNextPage() { 34 | return !NO_MORE_PAGES.equals(nextPageUrl); 35 | } 36 | 37 | /** 38 | * Gets the next page 39 | */ 40 | JsonArray nextPage() throws IOException { 41 | if (!hasNextPage()) { 42 | throw new IllegalStateException("Github API has no more issues to fetch. Did you run 'hasNextPage()' method?"); 43 | } 44 | 45 | GithubApi api = new GithubApi(githubToken); 46 | GithubApi.Response response = api.get(nextPageUrl); 47 | 48 | nextPageUrl = getNextPageUrl(response.getLinkHeader()); 49 | 50 | return parseJsonFrom(response.getContent()); 51 | } 52 | 53 | private JsonArray parseJsonFrom(String content) { 54 | JsonValue result = Json.parse(content); 55 | return result.asArray(); 56 | } 57 | 58 | private String getNextPageUrl(String linkHeader) { 59 | if (linkHeader == null) { 60 | //expected when there are no results 61 | return NO_MORE_PAGES; 62 | } 63 | 64 | // See Github API doc : https://developer.github.com/guides/traversing-with-pagination/ 65 | // Link: ; rel="next", 66 | // ; rel="last" 67 | for (String linkRel : linkHeader.split(",")) { 68 | if (linkRel.contains("rel=\"next\"")) { 69 | return linkRel.substring( 70 | linkRel.indexOf("http"), 71 | linkRel.indexOf(">; rel=\"next\"")); 72 | } 73 | } 74 | return NO_MORE_PAGES; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GitLogProvider.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import org.gradle.api.logging.Logger; 4 | import org.gradle.api.logging.Logging; 5 | 6 | class GitLogProvider { 7 | 8 | private final static Logger LOG = Logging.getLogger(GitLogProvider.class); 9 | 10 | private final ProcessRunner runner; 11 | 12 | GitLogProvider(ProcessRunner runner) { 13 | this.runner = runner; 14 | } 15 | 16 | 17 | String getLog(String fromRev, String toRev, String format) { 18 | String fetch = "+refs/tags/" + fromRev + ":refs/tags/" + fromRev; 19 | String log = fromRev + ".." + toRev; 20 | 21 | try { 22 | runner.run("git", "fetch", "origin", fetch); 23 | } catch (Exception e) { 24 | //This is a non blocking problem because we still are able to run git log locally 25 | LOG.info("'git fetch' did not work, continuing running 'git log' locally."); 26 | //To avoid confusion, no stack trace in debug log, just the message: 27 | LOG.debug("'git fetch' problem: {}", e.getMessage()); 28 | } 29 | return runner.run("git", "log", format, log); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GithubApi.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import org.gradle.api.logging.Logger; 4 | import org.gradle.api.logging.Logging; 5 | 6 | import javax.net.ssl.HttpsURLConnection; 7 | import java.io.IOException; 8 | import java.io.OutputStream; 9 | import java.net.HttpURLConnection; 10 | import java.net.URL; 11 | import java.net.URLConnection; 12 | import java.nio.charset.StandardCharsets; 13 | import java.util.Date; 14 | import java.util.Optional; 15 | import java.util.TimeZone; 16 | 17 | /** 18 | * Wrapper for making REST requests to Github API 19 | */ 20 | public class GithubApi { 21 | 22 | private static final Logger LOG = Logging.getLogger(GithubApi.class); 23 | 24 | private final String authToken; 25 | 26 | public GithubApi(String authToken) { 27 | this.authToken = authToken; 28 | } 29 | 30 | public String post(String url, String body) throws IOException { 31 | return doRequest(url, "POST", Optional.of(body)).content; 32 | } 33 | 34 | public String patch(String url, String body) throws IOException { 35 | return doRequest(url, "PATCH", Optional.of(body)).content; 36 | } 37 | 38 | public Response get(String url) throws IOException { 39 | return doRequest(url, "GET", Optional.empty()); 40 | } 41 | 42 | private Response doRequest(String urlString, String method, Optional body) throws IOException { 43 | URL url = new URL(urlString); 44 | 45 | HttpsURLConnection c = (HttpsURLConnection) url.openConnection(); 46 | //workaround for Java limitation (https://bugs.openjdk.java.net/browse/JDK-7016595), works with GitHub REST API 47 | if (method.equals("PATCH")) { 48 | c.setRequestMethod("POST"); 49 | } 50 | c.setDoOutput(true); 51 | c.setRequestProperty("Content-Type", "application/json"); 52 | if (method.equals("PATCH")) { 53 | c.setRequestProperty("X-HTTP-Method-Override", "PATCH"); 54 | } 55 | if (authToken != null) { 56 | c.setRequestProperty("Authorization", "token " + authToken); 57 | } 58 | 59 | if (body.isPresent()) { 60 | try (OutputStream os = c.getOutputStream()) { 61 | os.write(body.get().getBytes(StandardCharsets.UTF_8)); 62 | os.flush(); 63 | } 64 | } 65 | 66 | String resetInLocalTime = resetLimitInLocalTimeOrEmpty(c); 67 | 68 | String rateRemaining = c.getHeaderField("X-RateLimit-Remaining"); 69 | String rateLimit = c.getHeaderField("X-RateLimit-Limit"); 70 | //TODO instead of a lifecycle message, we should include the rate limiting information only when the request fails 71 | LOG.lifecycle("Github API rate info => Remaining : " + rateRemaining + ", Limit : " + rateLimit + ", Reset at: " + resetInLocalTime); 72 | 73 | String linkHeader = c.getHeaderField("Link"); 74 | LOG.info("Next page 'Link' from Github: {}", linkHeader); 75 | 76 | String content = call(method, c); 77 | return new Response(content, linkHeader); 78 | } 79 | 80 | private String resetLimitInLocalTimeOrEmpty(URLConnection urlConnection) { 81 | String rateLimitReset = urlConnection.getHeaderField("X-RateLimit-Reset"); 82 | if (rateLimitReset == null) { 83 | return ""; 84 | } 85 | Date resetInEpochSeconds = DateUtil.parseDateInEpochSeconds(rateLimitReset); 86 | return DateUtil.formatDateToLocalTime(resetInEpochSeconds, TimeZone.getDefault()); 87 | } 88 | 89 | private String call(String method, HttpsURLConnection conn) throws IOException { 90 | if (conn.getResponseCode() < HttpURLConnection.HTTP_BAD_REQUEST) { 91 | return IOUtil.readFully(conn.getInputStream()); 92 | } else { 93 | String errorMessage = 94 | String.format("%s %s failed, response code = %s, response body:%n%s", 95 | method, conn.getURL(), conn.getResponseCode(), IOUtil.readFully(conn.getErrorStream())); 96 | throw new ResponseException(conn.getResponseCode(), errorMessage); 97 | } 98 | } 99 | 100 | public static class ResponseException extends IOException { 101 | public final int responseCode; 102 | 103 | public ResponseException(int responseCode, String errorMessage) { 104 | super(errorMessage); 105 | this.responseCode = responseCode; 106 | } 107 | } 108 | 109 | public static class Response { 110 | 111 | private final String content; 112 | private final String linkHeader; 113 | 114 | public Response(String content, String linkHeader) { 115 | this.content = content; 116 | this.linkHeader = linkHeader; 117 | } 118 | 119 | public String getLinkHeader() { 120 | return linkHeader; 121 | } 122 | 123 | public String getContent() { 124 | return content; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GithubImprovementsJSON.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import com.eclipsesource.json.JsonObject; 4 | 5 | /** 6 | * Provides means to parse JsonObjects returned from calling Github API. 7 | */ 8 | class GithubImprovementsJSON { 9 | 10 | /** 11 | * Parses Github JsonObject in accordance to the API (https://developer.github.com/v3/issues/) 12 | * @param issue 13 | */ 14 | static Ticket toImprovement(JsonObject issue) { 15 | long id = issue.get("number").asLong(); 16 | String issueUrl = issue.get("html_url").asString(); 17 | String title = issue.get("title").asString(); 18 | 19 | return new Ticket(id, title, issueUrl); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/GithubTicketFetcher.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import com.eclipsesource.json.JsonArray; 4 | import com.eclipsesource.json.JsonValue; 5 | 6 | import java.util.*; 7 | import java.util.logging.Logger; 8 | 9 | class GithubTicketFetcher { 10 | 11 | private static final Logger LOG = Logger.getLogger(GithubTicketFetcher.class.getName()); 12 | private final GithubListFetcher fetcher; 13 | 14 | GithubTicketFetcher(String apiUrl, String repository, String githubToken) { 15 | this(new GithubListFetcher(apiUrl, repository, githubToken)); 16 | } 17 | 18 | GithubTicketFetcher(GithubListFetcher fetcher) { 19 | this.fetcher = fetcher; 20 | } 21 | 22 | Collection fetchTickets(Collection ticketIds) { 23 | List out = new LinkedList<>(); 24 | if (ticketIds.isEmpty()) { 25 | return out; 26 | } 27 | LOG.info("Querying Github API for " + ticketIds.size() + " tickets."); 28 | 29 | Queue tickets = queuedTicketNumbers(ticketIds); 30 | 31 | try { 32 | while (!tickets.isEmpty() && fetcher.hasNextPage()) { 33 | JsonArray page = fetcher.nextPage(); 34 | 35 | out.addAll(extractImprovements( 36 | dropTicketsAboveMaxInPage(tickets, page), 37 | page)); 38 | } 39 | } catch (Exception e) { 40 | throw new RuntimeException("Problems fetching " + ticketIds.size() + " tickets from Github", e); 41 | } 42 | return out; 43 | } 44 | 45 | /** 46 | * Remove the ticket IDs that are higher than the highest ticket in the page. 47 | * This prevents continuation of requests to find a ticket that will never be found. 48 | * TODO: we should fail in this case 49 | */ 50 | private Queue dropTicketsAboveMaxInPage(Queue tickets, JsonArray page) { 51 | if (page.isEmpty()) { 52 | return tickets; 53 | } 54 | long highestId = page.get(0).asObject().get("number").asLong(); 55 | while (!tickets.isEmpty() && tickets.peek() > highestId) { 56 | tickets.poll(); 57 | } 58 | return tickets; 59 | } 60 | 61 | private Queue queuedTicketNumbers(Collection ticketIds) { 62 | List tickets = new ArrayList<>(); 63 | for (String id : ticketIds) { 64 | tickets.add(Long.parseLong(id)); 65 | } 66 | Collections.sort(tickets); 67 | PriorityQueue longs = new PriorityQueue<>(tickets.size(), Collections.reverseOrder()); 68 | longs.addAll(tickets); 69 | return longs; 70 | } 71 | 72 | private static List extractImprovements(Collection tickets, JsonArray issues) { 73 | if (tickets.isEmpty()) { 74 | return Collections.emptyList(); 75 | } 76 | 77 | List pagedTickets = new ArrayList<>(); 78 | for (JsonValue issue : issues) { 79 | Ticket i = GithubImprovementsJSON.toImprovement(issue.asObject()); 80 | if (tickets.remove(i.getId())) { 81 | pagedTickets.add(i); 82 | if (tickets.isEmpty()) { 83 | return pagedTickets; 84 | } 85 | } 86 | } 87 | return pagedTickets; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/IOUtil.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import java.io.*; 4 | import java.nio.charset.StandardCharsets; 5 | import java.util.Scanner; 6 | 7 | /** 8 | * IO utils. A bit of reinventing the wheel but we don't want extra dependencies at this stage and we want to be java. 9 | */ 10 | public class IOUtil { 11 | 12 | /** 13 | * Reads string from the file 14 | */ 15 | public static String readFully(File input) { 16 | try(InputStream i = new FileInputStream(input)) { 17 | return readNow(i); 18 | } catch (Exception e) { 19 | throw new RuntimeException("Problems reading from: " + input, e); 20 | } 21 | } 22 | 23 | /** 24 | * Reads string from the stream and closes it 25 | */ 26 | public static String readFully(InputStream input) { 27 | try { 28 | return readNow(input); 29 | } catch (Exception e) { 30 | throw new RuntimeException("Problems reading from: " + input, e); 31 | } 32 | } 33 | 34 | private static String readNow(InputStream is) { 35 | try (Scanner s = new Scanner(is, StandardCharsets.UTF_8.name()).useDelimiter("\\A")) { 36 | return s.hasNext() ? s.next() : ""; 37 | } 38 | } 39 | 40 | public static void writeFile(File target, String content) { 41 | target.getParentFile().mkdirs(); 42 | try(PrintWriter p = new PrintWriter(new OutputStreamWriter(new FileOutputStream(target), StandardCharsets.UTF_8))) { 43 | p.write(content); 44 | } catch (Exception e) { 45 | throw new RuntimeException("Problems writing text to file: " + target, e); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/ProcessRunner.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import java.io.File; 4 | 5 | import static org.shipkit.changelog.IOUtil.readFully; 6 | 7 | class ProcessRunner { 8 | 9 | private final File workDir; 10 | 11 | ProcessRunner(File workDir) { 12 | this.workDir = workDir; 13 | } 14 | 15 | String run(String... commandLine) { 16 | int exitValue; 17 | String output; 18 | try { 19 | Process process = new ProcessBuilder(commandLine).directory(workDir).redirectErrorStream(true).start(); 20 | output = readFully(process.getInputStream()); 21 | exitValue = process.waitFor(); 22 | } catch (Exception e) { 23 | throw new RuntimeException("Problems executing command:\n " + String.join("\n", commandLine), e); 24 | } 25 | 26 | if (exitValue != 0) { 27 | throw new RuntimeException( 28 | "Problems executing command (exit code: " + exitValue + "):\n" + 29 | " command: " + String.join(" ", commandLine) + "\n" + 30 | " working dir: " + workDir.getAbsolutePath() + "\n" + 31 | " output:\n" + output); 32 | } 33 | 34 | return output; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/Ticket.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | /** 4 | * Simple POJO that contains all the information of an improvement 5 | */ 6 | class Ticket { 7 | 8 | private final Long id; 9 | private final String title; 10 | private final String url; 11 | 12 | Ticket(Long id, String title, String url) { 13 | this.id = id; 14 | this.title = title; 15 | this.url = url; 16 | } 17 | 18 | Long getId() { 19 | return id; 20 | } 21 | 22 | String getTitle() { 23 | return title; 24 | } 25 | 26 | String getUrl() { 27 | return url; 28 | } 29 | 30 | @Override 31 | public String toString() { 32 | return "{" + 33 | "id=" + id + 34 | ", title='" + title + '\'' + 35 | ", url='" + url + '\'' + 36 | '}'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/changelog/TicketParser.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog; 2 | 3 | import java.util.LinkedHashSet; 4 | import java.util.Set; 5 | import java.util.regex.Matcher; 6 | import java.util.regex.Pattern; 7 | 8 | class TicketParser { 9 | 10 | /** 11 | * Collects all ticked ids found in text, ticket format is #123 12 | */ 13 | static Set parseTickets(String text) { 14 | Set tickets = new LinkedHashSet<>(); 15 | Pattern ticket = Pattern.compile("#\\d+"); 16 | Matcher m = ticket.matcher(text); 17 | while (m.find()) { 18 | String ticketId = m.group().substring(1); //remove leading '#' 19 | tickets.add(ticketId); 20 | } 21 | return tickets; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/github/release/GithubReleasePlugin.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.github.release; 2 | 3 | import org.gradle.api.Plugin; 4 | import org.gradle.api.Project; 5 | 6 | /** 7 | * The plugin, ideally with zero business logic, but only the Gradle integration code 8 | */ 9 | public class GithubReleasePlugin implements Plugin { 10 | 11 | public void apply(Project project) { 12 | project.getTasks().register("githubRelease", GithubReleaseTask.class, t -> { 13 | t.setGithubApiUrl("https://api.github.com"); 14 | String tagName = "v" + project.getVersion();// 15 | t.setReleaseTag(tagName); 16 | t.setReleaseName(tagName); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/org/shipkit/github/release/GithubReleaseTask.java: -------------------------------------------------------------------------------- 1 | package org.shipkit.github.release; 2 | 3 | import com.eclipsesource.json.Json; 4 | import com.eclipsesource.json.JsonObject; 5 | import com.eclipsesource.json.JsonValue; 6 | import org.gradle.api.DefaultTask; 7 | import org.gradle.api.GradleException; 8 | import org.gradle.api.logging.Logger; 9 | import org.gradle.api.logging.Logging; 10 | import org.gradle.api.tasks.Input; 11 | import org.gradle.api.tasks.InputFile; 12 | import org.gradle.api.tasks.TaskAction; 13 | import org.shipkit.changelog.GithubApi; 14 | import org.shipkit.changelog.IOUtil; 15 | 16 | import java.io.File; 17 | import java.io.IOException; 18 | import java.util.Optional; 19 | 20 | public class GithubReleaseTask extends DefaultTask { 21 | 22 | private final static Logger LOG = Logging.getLogger(GithubReleaseTask.class); 23 | 24 | private String githubApiUrl = null; 25 | private String repository = null; 26 | private String releaseName = null; 27 | private String releaseTag = null; 28 | private File changelog = null; 29 | private String githubToken = null; 30 | private String newTagRevision = null; 31 | 32 | @Input 33 | public String getGithubApiUrl() { 34 | return githubApiUrl; 35 | } 36 | 37 | public void setGithubApiUrl(String githubApiUrl) { 38 | this.githubApiUrl = githubApiUrl; 39 | } 40 | 41 | @Input 42 | public String getRepository() { 43 | return repository; 44 | } 45 | 46 | public void setRepository(String repository) { 47 | this.repository = repository; 48 | } 49 | 50 | @Input 51 | public String getReleaseName() { 52 | return releaseName; 53 | } 54 | 55 | public void setReleaseName(String releaseName) { 56 | this.releaseName = releaseName; 57 | } 58 | 59 | /** 60 | * Release tag, for example "v1.2.3". 61 | * One of the parameters of the GitHub API call that creates GitHub release and the Git tag. 62 | */ 63 | @Input 64 | public String getReleaseTag() { 65 | return releaseTag; 66 | } 67 | 68 | /** 69 | * See {@link #getReleaseTag()} 70 | */ 71 | public void setReleaseTag(String releaseTag) { 72 | this.releaseTag = releaseTag; 73 | } 74 | 75 | @InputFile 76 | public File getChangelog() { 77 | return changelog; 78 | } 79 | 80 | public void setChangelog(File changelog) { 81 | this.changelog = changelog; 82 | } 83 | 84 | /** 85 | * Deprecated, please use {@link #getGithubToken()} 86 | */ 87 | @Input 88 | @Deprecated 89 | public String getWriteToken() { 90 | return getGithubToken(); 91 | } 92 | 93 | /** 94 | * Deprecated, please use {@link #setGithubToken(String)} 95 | */ 96 | @Deprecated 97 | public void setWriteToken(String writeToken) { 98 | this.setGithubToken(writeToken); 99 | } 100 | 101 | /** 102 | * See {@link #setGithubToken(String)} 103 | */ 104 | @Input 105 | public String getGithubToken() { 106 | return githubToken; 107 | } 108 | 109 | /** 110 | * Token required by Github API to post a new release. 111 | * This token should have *write* permission to the repo. 112 | * 113 | * @param githubToken token with write permissions 114 | */ 115 | public void setGithubToken(String githubToken) { 116 | this.githubToken = githubToken; 117 | } 118 | 119 | /** 120 | * See {@link #setNewTagRevision(String)} 121 | */ 122 | @Input 123 | public String getNewTagRevision() { 124 | return newTagRevision; 125 | } 126 | 127 | /** 128 | * Property required to specify revision for the new tag. 129 | * The property's value is passed to Github API's 130 | * 'target_commitish' parameter in {@link #postRelease()} method. 131 | */ 132 | public void setNewTagRevision(String newTagRevision) { 133 | this.newTagRevision = newTagRevision; 134 | } 135 | 136 | @TaskAction public void postRelease() { 137 | String url = githubApiUrl + "/repos/" + repository + "/releases"; 138 | 139 | JsonObject body = new JsonObject(); 140 | body.add("tag_name", releaseTag); 141 | body.add("name", releaseName); 142 | body.add("target_commitish", newTagRevision); 143 | String releaseNotesTxt = IOUtil.readFully(changelog); 144 | body.add("body", releaseNotesTxt); 145 | 146 | GithubApi githubApi = new GithubApi(githubToken); 147 | 148 | try { 149 | LOG.lifecycle("Checking if release exists for tag {}...", releaseTag); 150 | Optional existingRelease = existingRelease(githubApi, url, releaseTag); 151 | final String htmlUrl = performRelease(existingRelease, githubApi, url, body.toString()); 152 | LOG.lifecycle("Posted release to Github: " + htmlUrl); 153 | } catch (IOException e) { 154 | throw new GradleException("Unable to post release to Github.\n" + 155 | " * url: " + url + "\n" + 156 | " * release tag: " + releaseTag + "\n" + 157 | " * release name: " + releaseName + "\n" + 158 | " * token: " + githubToken.substring(0, 3) + "...\n" + 159 | " * content:\n" + releaseNotesTxt + "\n\n" + 160 | " * underlying problem: " + e.getMessage() + "\n" + 161 | " * troubleshooting: please run Gradle with '-s' to see the full stack trace or inspect the build scan\n" + 162 | " * thank you for using Shipkit!" 163 | , e); 164 | } 165 | } 166 | 167 | /** 168 | * Updates an existing release or creates a new release. 169 | * @param existingReleaseId if empty, new release will created. 170 | * If it contains release ID (internal GH identifier) it will update that release 171 | * @param githubApi the GH api object 172 | * @param url the url to use 173 | * @param body payload 174 | * @return String with JSON contents 175 | * @throws IOException when something goes wrong with REST call / HTTP connectivity 176 | */ 177 | String performRelease(Optional existingReleaseId, GithubApi githubApi, String url, String body) throws IOException { 178 | final String htmlUrl; 179 | if (existingReleaseId.isPresent()) { 180 | LOG.lifecycle("Release already exists for tag {}! Updating the release notes...", releaseTag); 181 | 182 | String response = githubApi.patch(url + "/" + existingReleaseId.get(), body); 183 | htmlUrl = Json.parse(response).asObject().getString("html_url", ""); 184 | } else { 185 | String response = githubApi.post(url, body); 186 | htmlUrl = Json.parse(response).asObject().getString("html_url", ""); 187 | } 188 | return htmlUrl; 189 | } 190 | 191 | /** 192 | * Finds out if the release for given tag already exists 193 | * 194 | * @param githubApi api object 195 | * @param url main REST url 196 | * @param releaseTag the tag name, will be appended to the url 197 | * @return existing release ID or empty optional if there is no release for the given tag 198 | * @throws IOException when something goes wrong with REST call / HTTP connectivity 199 | */ 200 | Optional existingRelease(GithubApi githubApi, String url, String releaseTag) throws IOException { 201 | try { 202 | GithubApi.Response r = githubApi.get(url + "/tags/" + releaseTag); 203 | JsonValue result = Json.parse(r.getContent()); 204 | int releaseId = result.asObject().getInt("id", -1); 205 | return Optional.of(releaseId); 206 | } catch (GithubApi.ResponseException e) { 207 | return Optional.empty(); 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/ChangelogFormatTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import spock.lang.Specification 4 | 5 | class ChangelogFormatTest extends Specification { 6 | 7 | def "creates changelog"() { 8 | def improvements = [ 9 | new Ticket(11, "fixed bug", "https://github.com/myorg/myrepo/pulls/11"), 10 | new Ticket(12, "improved feature", "https://github.com/myorg/myrepo/issues/12"), 11 | ] 12 | when: 13 | def changelog = ChangelogFormat.formatChangelog(['mockitoguy', 'john'], improvements, 5, "v1.0.0", 14 | "1.0.0", "v0.0.9", "https://github.com/myorg/myrepo", 15 | "2020-01-01") 16 | 17 | then: 18 | changelog == """*Changelog generated by [Shipkit Changelog Gradle Plugin](https://github.com/shipkit/shipkit-changelog)* 19 | 20 | #### 1.0.0 21 | - 2020-01-01 - [5 commit(s)](https://github.com/myorg/myrepo/compare/v0.0.9...v1.0.0) by mockitoguy, john 22 | - fixed bug [(#11)](https://github.com/myorg/myrepo/pulls/11) 23 | - improved feature [(#12)](https://github.com/myorg/myrepo/issues/12)""" 24 | } 25 | 26 | def "no improvements"() { 27 | when: 28 | def changelog = ChangelogFormat.formatChangelog(['mockitoguy'], [], 2, "2.0.0", 29 | "2.0.0", "1.5.5", "https://github.com/myorg/myrepo", 30 | "2020-01-01") 31 | 32 | then: 33 | changelog == """*Changelog generated by [Shipkit Changelog Gradle Plugin](https://github.com/shipkit/shipkit-changelog)* 34 | 35 | #### 2.0.0 36 | - 2020-01-01 - [2 commit(s)](https://github.com/myorg/myrepo/compare/1.5.5...2.0.0) by mockitoguy 37 | - No notable improvements. No pull requests (issues) were referenced from commits.""" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/ChangelogPluginTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import org.gradle.testfixtures.ProjectBuilder 4 | import spock.lang.Specification 5 | 6 | class ChangelogPluginTest extends Specification { 7 | 8 | def project = new ProjectBuilder().build() 9 | 10 | def "applies cleanly"() { 11 | when: 12 | project.plugins.apply(ChangelogPlugin) 13 | 14 | then: 15 | project.tasks.generateChangelog 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/DateUtilTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import spock.lang.Specification 4 | 5 | import static org.shipkit.changelog.DateUtil.* 6 | 7 | class DateUtilTest extends Specification { 8 | 9 | def "parses UTC date"() { 10 | //Using any date to assert the logic: 11 | def date = new Date(1593930840832) 12 | 13 | expect: 14 | formatDate(date) == "2020-07-05" 15 | formatDateToLocalTime(date, TimeZone.getTimeZone("GMT")) == "2020-07-05 06:34 AM GMT" 16 | parseDateInEpochSeconds("1593930840") == new Date(1593930840000) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/GenerateChangelogTaskTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import org.junit.Rule 4 | import org.junit.rules.TemporaryFolder 5 | import spock.lang.Specification 6 | 7 | import static org.shipkit.changelog.GenerateChangelogTask.provideGitDir 8 | 9 | class GenerateChangelogTaskTest extends Specification { 10 | 11 | @Rule TemporaryFolder tmp = new TemporaryFolder() 12 | 13 | def "getGitDir is safe"() { 14 | expect: 15 | provideGitDir(null) == null 16 | provideGitDir(new File("missing")) == null 17 | 18 | and: 19 | def gitDir = tmp.newFolder(".git") 20 | provideGitDir(gitDir.parentFile) == gitDir 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/GitCommitProviderTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import spock.lang.Specification 4 | import spock.lang.Subject 5 | 6 | class GitCommitProviderTest extends Specification { 7 | 8 | def logProvider = Mock(GitLogProvider) 9 | @Subject 10 | provider = new GitCommitProvider(logProvider) 11 | 12 | def log = """a5797f9e6cfc06e2fa70ed12ee6c9571af8a7fc9@@info@@mockitoguy@gmail.com@@info@@Szczepan Faber@@info@@Tidy-up in buildSrc 13 | Merged pull request #10 14 | @@commit@@ 15 | b9d694f4c25880d9dda21ac216053f2bd0f5673c@@info@@mockitoguy@gmail.com@@info@@Szczepan Faber@@info@@Tidy-up in buildSrc - fixes #20 and #30 16 | @@commit@@ 17 | c76924d41c219f3b71b50a28d80c23c9c81b7a8c@@info@@john@doe@@info@@John R. Doe@@info@@dummy commit 18 | @@commit@@""" 19 | 20 | def "provides commits"() { 21 | logProvider.getLog("v1.10.10", "HEAD", "--pretty=format:%H@@info@@%ae@@info@@%an@@info@@%B%N@@commit@@") >> log 22 | 23 | when: 24 | def commits = provider.getCommits("v1.10.10", "HEAD") 25 | 26 | then: 27 | commits.join("\n") == """{author='Szczepan Faber', tickets=[10]} 28 | {author='Szczepan Faber', tickets=[20, 30]} 29 | {author='John R. Doe', tickets=[]}""" 30 | } 31 | 32 | def "has basic handling of garbage in log"() { 33 | logProvider.getLog(_, _, _) >> (log + " some garbage \n@@commit@@\n more garbage") 34 | 35 | when: 36 | def commits = provider.getCommits("v1.10.10", "HEAD") 37 | 38 | then: 39 | commits.size() == 3 40 | } 41 | 42 | def "handles empty log"() { 43 | logProvider.getLog(_, _, _) >> "" 44 | 45 | when: 46 | def commits = provider.getCommits("v1.10.10", "HEAD") 47 | 48 | then: 49 | commits.isEmpty() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/GitLogProviderTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import org.junit.Rule 4 | import org.junit.rules.TemporaryFolder 5 | import spock.lang.Specification 6 | 7 | class GitLogProviderTest extends Specification { 8 | 9 | @Rule TemporaryFolder tmp = new TemporaryFolder() 10 | ProcessRunner runner 11 | GitLogProvider provider 12 | 13 | def setup() { 14 | runner = new ProcessRunner(tmp.root) 15 | 16 | runner.run("git", "init") 17 | runner.run("git", "config", "user.email", "dummy@testing.com") 18 | runner.run("git", "config", "user.name", "Dummy For Testing") 19 | 20 | runner.run("git", "commit", "--allow-empty", "-m", "the initial commit") 21 | runner.run("git", "tag", "v0.0.0") 22 | runner.run("git", "commit", "--allow-empty", "-m", "the second test commit") 23 | runner.run("git", "tag", "v0.0.1") 24 | 25 | provider = new GitLogProvider(runner) 26 | } 27 | 28 | def "smoke test"() { 29 | when: 30 | def log = provider.getLog("v0.0.0", "v0.0.1", "--pretty=short") 31 | 32 | then: 33 | !log.contains("the initial commit") 34 | log.contains("the second test commit") 35 | } 36 | 37 | def "no previous revision"() { 38 | //this is the "first release" use case 39 | when: 40 | def log = provider.getLog("HEAD", "v0.0.1", "--pretty=short") 41 | 42 | then: 43 | log == "" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/GithubImprovementsJSONTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import com.eclipsesource.json.Json 4 | import spock.lang.Specification 5 | 6 | class GithubImprovementsJSONTest extends Specification { 7 | 8 | def "parses issue"() { 9 | def issue = Json.parse('{"number": 100, "html_url": "http://issues/100", "title": "Some bugfix"}') 10 | .asObject() 11 | 12 | when: 13 | def i = GithubImprovementsJSON.toImprovement(issue) 14 | 15 | then: 16 | i.id == 100L 17 | i.title == "Some bugfix" 18 | i.url == "http://issues/100" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/GithubReleaseTaskTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import org.gradle.api.GradleException 4 | import org.gradle.testfixtures.ProjectBuilder 5 | import org.shipkit.github.release.GithubReleasePlugin 6 | import org.shipkit.github.release.GithubReleaseTask 7 | import spock.lang.Specification 8 | 9 | class GithubReleaseTaskTest extends Specification { 10 | 11 | def apiMock = Mock(GithubApi) 12 | def project = ProjectBuilder.builder().build() 13 | 14 | def setup() { 15 | project.plugins.apply(GithubReleasePlugin) 16 | } 17 | 18 | def "knows if release already exists"() { 19 | GithubReleaseTask task = project.tasks.githubRelease 20 | apiMock.get("dummy/releases/tags/v1.2.3") >> new GithubApi.Response('{"id": 10}', '') 21 | 22 | when: 23 | def result = task.existingRelease(apiMock, "dummy/releases", "v1.2.3") 24 | 25 | then: 26 | result.get() == 10 27 | } 28 | 29 | def "knows if release does not yet exist"() { 30 | GithubReleaseTask task = project.tasks.githubRelease 31 | apiMock.get("dummy/releases/tags/v1.2.3") >> { throw new GithubApi.ResponseException(404, "") } 32 | 33 | when: 34 | def result = task.existingRelease(apiMock, "dummy/releases", "v1.2.3") 35 | 36 | then: 37 | !result.present 38 | } 39 | 40 | def "creates new release"() { 41 | GithubReleaseTask task = project.tasks.githubRelease 42 | apiMock.post("dummy/url", "dummy body") >> '{"html_url": "dummy html url"}' 43 | 44 | when: 45 | def result = task.performRelease(Optional.empty(), apiMock, "dummy/url", "dummy body") 46 | 47 | then: 48 | result == "dummy html url" 49 | } 50 | 51 | def "updates existing release"() { 52 | GithubReleaseTask task = project.tasks.githubRelease 53 | apiMock.patch("api/releases/123", "dummy body") >> '{"html_url": "dummy html url"}' 54 | 55 | when: 56 | def result = task.performRelease(Optional.of(123), apiMock, "api/releases", "dummy body") 57 | 58 | then: 59 | result == "dummy html url" 60 | } 61 | 62 | /** 63 | * Update githubToken and repo name for manual integration testing 64 | */ 65 | def "manual integration test"() { 66 | project.version = "1.2.4" 67 | project.file("changelog.md") << "Spanking new release! " + System.currentTimeSeconds() 68 | project.tasks.named("githubRelease") { GithubReleaseTask it -> 69 | it.changelog = project.file("changelog.md") 70 | it.repository = "mockitoguy/shipkit-demo" //feel free to change to your private repo 71 | it.newTagRevision = "aa51a6fe99d710c0e7ca30fc1d0411a8e9cdb7a8" //use sha of the repo above 72 | it.githubToken = "secret" //update, use your token, DON'T CHECK IN 73 | } 74 | 75 | when: 76 | project.tasks.githubRelease.postRelease() 77 | 78 | then: 79 | //when doing manual integration testing you won't get an exception here 80 | //remove below / change the assertion when integ testing 81 | thrown(GradleException) 82 | // true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/GithubTicketFetcherIntegTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import spock.lang.Ignore 4 | import spock.lang.Specification 5 | 6 | class GithubTicketFetcherIntegTest extends Specification { 7 | 8 | /** 9 | * For local development please remove @Ignore and provide your token for GitHub 10 | */ 11 | @Ignore 12 | def "fetches from Github"() { 13 | def fetcher = new GithubTicketFetcher("https://api.github.com", "mockito/mockito", 14 | "TODO-put-your-own-token-here") 15 | 16 | when: 17 | //TODO: we need to query a repo that is dedicated for this test and validate that pagination works 18 | def tickets = fetcher.fetchTickets(["1928", "1922", "1927"]) 19 | 20 | then: 21 | tickets.join("\n") == """{id=1928, title='JUnit 5 strict stubs check should not suppress the regular test failure', url='https://github.com/mockito/mockito/pull/1928'} 22 | {id=1927, title='Fix import order', url='https://github.com/mockito/mockito/pull/1927'} 23 | {id=1922, title='[build] add ben-manes dependency upgrade finder', url='https://github.com/mockito/mockito/pull/1922'}""" 24 | } 25 | 26 | def "fetches from Github without token"() { 27 | def fetcher = new GithubTicketFetcher("https://api.github.com", "mockito/mockito", null) 28 | 29 | when: 30 | //TODO: we need to query a repo that is dedicated for this test and validate that pagination works 31 | def tickets = fetcher.fetchTickets(["1927"]) 32 | 33 | then: 34 | tickets.join("\n") == """{id=1927, title='Fix import order', url='https://github.com/mockito/mockito/pull/1927'}""" 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/GithubTicketFetcherTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import com.eclipsesource.json.Json 4 | import com.eclipsesource.json.JsonArray 5 | import spock.lang.Specification 6 | 7 | class GithubTicketFetcherTest extends Specification { 8 | 9 | def listFetcher = Mock(GithubListFetcher) 10 | def fetcher = new GithubTicketFetcher(listFetcher) 11 | 12 | def "empty tickets"() { 13 | expect: 14 | fetcher.fetchTickets([]).empty 15 | } 16 | 17 | def "fetches from 2 pages"() { 18 | listFetcher.hasNextPage() >>> [true, true, false] 19 | def page1 = Json.parse("""[{"number": 30, "html_url": "http://issues/x", "title": "fix1"}, 20 | {"number": 20, "html_url": "http://issues/x", "title": "fix2"}]""") 21 | def page2 = Json.parse("""[{"number": 10, "html_url": "http://issues/x", "title": "fix3"}]""") 22 | listFetcher.nextPage() >>> [page1, page2] 23 | 24 | when: 25 | def tickets = fetcher.fetchTickets(["10", "30", "40"]) 26 | 27 | then: 28 | tickets.join("\n") == """{id=30, title='fix1', url='http://issues/x'} 29 | {id=10, title='fix3', url='http://issues/x'}""" 30 | } 31 | 32 | def "fetches empty page"() { 33 | listFetcher.hasNextPage() >>> [true, false] 34 | listFetcher.nextPage() >> new JsonArray() 35 | 36 | when: 37 | def tickets = fetcher.fetchTickets(["10", "30"]) 38 | 39 | then: 40 | tickets.empty 41 | } 42 | 43 | def "ticket out of range"() { 44 | listFetcher.hasNextPage() >>> [true, false] 45 | listFetcher.nextPage() >> Json.parse("""[{"number": 10, "html_url": "x", "title": "y"}]""") 46 | 47 | when: 48 | def tickets = fetcher.fetchTickets(["100"]) 49 | 50 | then: 51 | tickets.empty 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/IOUtilTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import org.junit.Rule 4 | import org.junit.rules.TemporaryFolder 5 | import spock.lang.Specification 6 | 7 | import static org.shipkit.changelog.IOUtil.readFully 8 | import static org.shipkit.changelog.IOUtil.writeFile 9 | 10 | class IOUtilTest extends Specification { 11 | 12 | @Rule TemporaryFolder tmp = new TemporaryFolder() 13 | 14 | def "reads stream"() { 15 | expect: 16 | readFully(new ByteArrayInputStream("hey\njoe!".bytes)) == "hey\njoe!" 17 | readFully(new ByteArrayInputStream("\n".bytes)) == "\n" 18 | readFully(new ByteArrayInputStream("\n\n".bytes)) == "\n\n" 19 | readFully(new ByteArrayInputStream("".bytes)) == "" 20 | } 21 | 22 | def "writes file"() { 23 | def f = new File(tmp.root, "x/y/z.txt") 24 | writeFile(f, "ala\nma") 25 | 26 | expect: 27 | readFully(f) == "ala\nma" 28 | } 29 | 30 | def "clean exception when reading file"() { 31 | when: 32 | readFully((File) null) 33 | 34 | then: 35 | def e = thrown(RuntimeException) 36 | e.message == "Problems reading from: null" 37 | e.cause 38 | } 39 | 40 | def "clean exception when reading stream"() { 41 | when: 42 | readFully((InputStream) null) 43 | 44 | then: 45 | def e = thrown(RuntimeException) 46 | e.message == "Problems reading from: null" 47 | e.cause 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/ProcessRunnerTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import org.junit.Rule 4 | import org.junit.rules.TemporaryFolder 5 | import spock.lang.IgnoreIf 6 | import spock.lang.Specification 7 | 8 | //ignore the test when there is no 'ls' utility 9 | @IgnoreIf({ !commandAvailable("ls") }) 10 | class ProcessRunnerTest extends Specification { 11 | 12 | @Rule TemporaryFolder tmp = new TemporaryFolder() 13 | 14 | def "runs processes and returns output"() { 15 | File dir = tmp.newFolder() 16 | new File(dir, "xyz.txt").createNewFile() 17 | new File(dir, "hey joe.jar").createNewFile() 18 | 19 | when: 20 | String output = new ProcessRunner(dir).run("ls") 21 | 22 | then: 23 | output.contains("xyz.txt") 24 | output.contains("hey joe.jar") 25 | } 26 | 27 | def "fails to start process"() { 28 | when: 29 | new ProcessRunner(tmp.root).run("bad-cli") 30 | 31 | then: 32 | def e = thrown(RuntimeException) 33 | e.message == """Problems executing command: 34 | bad-cli""" 35 | e.cause 36 | } 37 | 38 | def "process failure"() { 39 | when: 40 | new ProcessRunner(tmp.root).run("ls", "-bad-option") 41 | 42 | then: 43 | def e = thrown(RuntimeException) 44 | e.message.startsWith "Problems executing command (exit code:" 45 | } 46 | 47 | static boolean commandAvailable(String command) { 48 | try { 49 | return command.execute().waitFor() == 0 50 | } catch (Exception e) { 51 | return false 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/test/groovy/org/shipkit/changelog/TicketParserTest.groovy: -------------------------------------------------------------------------------- 1 | package org.shipkit.changelog 2 | 3 | import spock.lang.Specification 4 | 5 | import static org.shipkit.changelog.TicketParser.parseTickets 6 | 7 | class TicketParserTest extends Specification { 8 | 9 | def "no referenced tickets"() { 10 | expect: 11 | parseTickets("").isEmpty() 12 | parseTickets("asdfasf").isEmpty() 13 | } 14 | 15 | def "knows referenced tickets"() { 16 | expect: 17 | parseTickets("#0") == ['0'] as Set 18 | parseTickets("#12 #12 #13 #14.0 #15k #-1") == ['12', '13', '14', '15'] as Set 19 | parseTickets("stuff 12 #133 44") == ['133'] as Set 20 | parseTickets("line\n a #12 x \n b #13 z \n ") == ['12', '13'] as Set 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /version.properties: -------------------------------------------------------------------------------- 1 | version=2.0.* 2 | --------------------------------------------------------------------------------