├── .changelog.yml ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── release.yml │ └── release_prep.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── config.go ├── get.go ├── new.go ├── parse.go ├── root.go └── show.go ├── go.mod ├── go.sum ├── internal ├── configuration │ ├── configuration.go │ └── configuration_test.go ├── environment │ ├── environment.go │ └── environment_test.go ├── get │ ├── CHANGELOG.md │ ├── get.go │ ├── get_test.go │ └── single_CHANGELOG.md ├── gitclient │ ├── client.go │ └── client_test.go ├── githubclient │ ├── client.go │ ├── client_test.go │ ├── data │ │ └── get_tags_response.json │ ├── pull_requests.go │ └── tags.go ├── logging │ ├── console.go │ ├── logger.go │ ├── logger_test.go │ └── spinner.go ├── show │ ├── show.go │ ├── show_test.go │ └── viewport.go ├── utils │ ├── utils.go │ └── utils_test.go ├── version │ └── version.go └── writer │ ├── writer.go │ └── writer_test.go ├── main.go ├── mocks ├── Builder.go ├── Changelog.go ├── GitClient.go ├── GitHubClient.go ├── Logger.go └── Parser.go └── pkg ├── builder ├── builder.go └── builder_test.go ├── changelog ├── changelog.go └── changelog_test.go ├── entry ├── entry.go └── entry_test.go └── parser ├── parser.go ├── parser_test.go └── testdata ├── no_unreleased.md └── unreleased.md /.changelog.yml: -------------------------------------------------------------------------------- 1 | check_for_updates: false 2 | excluded_labels: 3 | - maintenance 4 | - dependencies 5 | file_name: CHANGELOG.md 6 | logger: spinner 7 | no_color: false 8 | sections: 9 | added: 10 | - feature 11 | - enhancement 12 | changed: 13 | - backwards-incompatible 14 | fixed: 15 | - bug 16 | - bugfix 17 | - documentation 18 | show_unreleased: true 19 | skip_entries_without_label: false 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: "ci" 4 | 5 | on: 6 | pull_request: 7 | branches: 8 | - "main" 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: "write" 13 | 14 | env: 15 | GO_VERSION: 1.21 16 | 17 | jobs: 18 | ci: 19 | name: "ci" 20 | runs-on: "ubuntu-latest" 21 | steps: 22 | 23 | - name: "checkout" 24 | uses: "actions/checkout@v3" 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: "setup go" 29 | uses: "actions/setup-go@v3" 30 | with: 31 | go-version: ${{ env.GO_VERSION }} 32 | 33 | - name: "lint" 34 | uses: "golangci/golangci-lint-action@v3" 35 | with: 36 | version: "latest" 37 | 38 | - name: "test" 39 | run: | 40 | export GH_HOST=github.com 41 | go test -race -covermode=atomic -v ./... 42 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | schedule: 5 | - cron: '29 22 * * 0' 6 | 7 | jobs: 8 | analyze: 9 | name: Analyze 10 | runs-on: ubuntu-latest 11 | permissions: 12 | actions: read 13 | contents: read 14 | security-events: write 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | language: [ 'go' ] 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Initialize CodeQL 26 | uses: github/codeql-action/init@v2 27 | with: 28 | languages: ${{ matrix.language }} 29 | 30 | - name: Autobuild 31 | uses: github/codeql-action/autobuild@v2 32 | 33 | # ℹ️ Command-line programs to run using the OS shell. 34 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 35 | 36 | # If the Autobuild fails above, remove it and uncomment the following three lines. 37 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 38 | 39 | # - run: | 40 | # echo "Run, Build Application using script" 41 | # ./location_of_script_within_repo/buildscript.sh 42 | 43 | - name: Perform CodeQL Analysis 44 | uses: github/codeql-action/analyze@v2 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: "release" 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v*" 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: "write" 13 | 14 | env: 15 | GO_VERSION: 1.21 16 | 17 | jobs: 18 | release: 19 | name: "release" 20 | runs-on: "ubuntu-latest" 21 | env: 22 | WORKINGDIR: ${{ github.workspace }} 23 | steps: 24 | 25 | - name: "checkout" 26 | uses: "actions/checkout@v3" 27 | with: 28 | fetch-depth: 0 29 | 30 | - name: "setup go" 31 | uses: "actions/setup-go@v2" 32 | with: 33 | go-version: ${{ env.GO_VERSION }} 34 | 35 | - name: "release" 36 | uses: "goreleaser/goreleaser-action@v2" 37 | with: 38 | version: "latest" 39 | args: "release --rm-dist" 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/release_prep.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: "release prep" 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | version: 9 | description: "The next version that will be released" 10 | required: true 11 | 12 | permissions: 13 | contents: "write" 14 | pull-requests: "write" 15 | 16 | jobs: 17 | changelog: 18 | name: "changelog" 19 | runs-on: "ubuntu-latest" 20 | steps: 21 | - uses: "actions/checkout@v3" 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: "gh changelog new --next-version ${{ github.event.inputs.version }}" 26 | run: | 27 | export GH_HOST=github.com 28 | gh extension install chelnak/gh-changelog 29 | gh changelog new --next-version ${{ github.event.inputs.version }} 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: "create pull request" 34 | uses: "peter-evans/create-pull-request@v4" 35 | with: 36 | title: "Release prep for version ${{ github.event.inputs.version }}" 37 | commit-message: "automated changelog generation" 38 | body: "This PR contains an automatically generated changelog." 39 | base: "main" 40 | labels: 41 | "maintenance" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - dogsled 4 | - dupl 5 | - gofmt 6 | - goimports 7 | - gosec 8 | - misspell 9 | - nakedret 10 | - stylecheck 11 | - unconvert 12 | - unparam 13 | - whitespace 14 | - errcheck 15 | - gosimple 16 | - staticcheck 17 | - ineffassign 18 | - unused 19 | - staticcheck 20 | issues: 21 | exclude-use-default: false 22 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: gh-changelog 2 | 3 | release: 4 | name_template: "gh-changelog {{.Version}}" 5 | prerelease: auto 6 | 7 | before: 8 | hooks: 9 | - go mod tidy 10 | - go fmt ./... 11 | 12 | builds: 13 | - id: changelog 14 | binary: "{{ tolower .Os }}-{{ .Arch }}" 15 | env: 16 | - CGO_ENABLED=0 17 | goos: 18 | - linux 19 | - windows 20 | - darwin 21 | goarch: 22 | - amd64 23 | - arm 24 | - arm64 25 | asmflags: 26 | - all=-trimpath={{.Env.WORKINGDIR}} 27 | gcflags: 28 | - all=-trimpath={{.Env.WORKINGDIR}} 29 | ldflags: 30 | - -s -w -X github.com/chelnak/gh-changelog/cmd.version={{.Version}} 31 | mod_timestamp: '{{ .CommitTimestamp }}' 32 | no_unique_dist_dir: true 33 | 34 | 35 | archives: 36 | - format: binary 37 | name_template: "{{ tolower .Os }}-{{ .Arch }}" 38 | allow_different_binary_count: true 39 | 40 | checksum: 41 | name_template: 'checksums.txt' 42 | 43 | snapshot: 44 | name_template: "{{ .Tag }}-{{.ShortCommit}}" 45 | 46 | changelog: 47 | sort: asc 48 | filters: 49 | exclude: 50 | - '^docs:' 51 | - '^test:' -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). 7 | 8 | ## [0.15.3](https://github.com/chelnak/gh-changelog/tree/0.15.3) - 2024-05-03 9 | 10 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.15.2...0.15.3) 11 | 12 | ### Fixed 13 | 14 | - Fix get latest panic on single entry changelog [#151](https://github.com/chelnak/gh-changelog/pull/151) ([h0tw1r3](https://github.com/h0tw1r3)) 15 | 16 | ## [v0.15.2](https://github.com/chelnak/gh-changelog/tree/v0.15.2) - 2024-05-03 17 | 18 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.15.1...v0.15.2) 19 | 20 | ### Fixed 21 | 22 | - Fix panic when parsing unreleased entries [#149](https://github.com/chelnak/gh-changelog/pull/149) ([chelnak](https://github.com/chelnak)) 23 | - Fix no previous tag when using get cmd [#148](https://github.com/chelnak/gh-changelog/pull/148) ([h0tw1r3](https://github.com/h0tw1r3)) 24 | - Add missing line between "Changed" title and list [#146](https://github.com/chelnak/gh-changelog/pull/146) ([smortex](https://github.com/smortex)) 25 | 26 | ## [v0.15.1](https://github.com/chelnak/gh-changelog/tree/v0.15.1) - 2023-10-09 27 | 28 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.15.0...v0.15.1) 29 | 30 | ### Fixed 31 | 32 | - bugfix: Release creation toggling RepoName & RepoOwner [#142](https://github.com/chelnak/gh-changelog/pull/142) ([Ramesh7](https://github.com/Ramesh7)) 33 | 34 | ## [v0.15.0](https://github.com/chelnak/gh-changelog/tree/v0.15.0) - 2023-10-01 35 | 36 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.14.0...v0.15.0) 37 | 38 | ### Added 39 | 40 | - Improve sections ordering [#139](https://github.com/chelnak/gh-changelog/pull/139) ([smortex](https://github.com/smortex)) 41 | 42 | ## [v0.14.0](https://github.com/chelnak/gh-changelog/tree/v0.14.0) - 2023-05-12 43 | 44 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.13.1...v0.14.0) 45 | 46 | ### Added 47 | 48 | - Get command [#137](https://github.com/chelnak/gh-changelog/pull/137) ([chelnak](https://github.com/chelnak)) 49 | 50 | ## [v0.13.1](https://github.com/chelnak/gh-changelog/tree/v0.13.1) - 2023-04-25 51 | 52 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.13.0...v0.13.1) 53 | 54 | ### Fixed 55 | 56 | - Patch Version Parsing Code [#135](https://github.com/chelnak/gh-changelog/pull/135) ([chelnak](https://github.com/chelnak)) 57 | 58 | ## [v0.13.0](https://github.com/chelnak/gh-changelog/tree/v0.13.0) - 2023-04-15 59 | 60 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.12.1...v0.13.0) 61 | 62 | ### Added 63 | 64 | - Support secure credential storage [#133](https://github.com/chelnak/gh-changelog/pull/133) ([chelnak](https://github.com/chelnak)) 65 | 66 | ## [v0.12.1](https://github.com/chelnak/gh-changelog/tree/v0.12.1) - 2023-04-12 67 | 68 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.12.0...v0.12.1) 69 | 70 | ### Fixed 71 | 72 | - Fix config initialization [#129](https://github.com/chelnak/gh-changelog/pull/129) ([chelnak](https://github.com/chelnak)) 73 | 74 | ## [v0.12.0](https://github.com/chelnak/gh-changelog/tree/v0.12.0) - 2023-04-11 75 | 76 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.11.0...v0.12.0) 77 | 78 | ### Added 79 | 80 | - Allow local configs [#119](https://github.com/chelnak/gh-changelog/pull/119) ([chelnak](https://github.com/chelnak)) 81 | - Add a Markdown parser [#117](https://github.com/chelnak/gh-changelog/pull/117) ([chelnak](https://github.com/chelnak)) 82 | 83 | ### Fixed 84 | 85 | - Handle Pre-Releases [#126](https://github.com/chelnak/gh-changelog/pull/126) ([chelnak](https://github.com/chelnak)) 86 | 87 | ## [v0.11.0](https://github.com/chelnak/gh-changelog/tree/v0.11.0) - 2022-12-01 88 | 89 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.10.1...v0.11.0) 90 | 91 | ### Added 92 | 93 | - Convert changelog datastructure [#112](https://github.com/chelnak/gh-changelog/pull/112) ([chelnak](https://github.com/chelnak)) 94 | 95 | ### Fixed 96 | 97 | - Fix usage on repositories without tags [#114](https://github.com/chelnak/gh-changelog/pull/114) ([chelnak](https://github.com/chelnak)) 98 | 99 | ### Other 100 | 101 | - Fix markown formatting [#113](https://github.com/chelnak/gh-changelog/pull/113) ([chelnak](https://github.com/chelnak)) 102 | 103 | ## [v0.10.1](https://github.com/chelnak/gh-changelog/tree/v0.10.1) - 2022-10-20 104 | 105 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.10.0...v0.10.1) 106 | 107 | ### Fixed 108 | 109 | - Fix tags append [#109](https://github.com/chelnak/gh-changelog/pull/109) ([chelnak](https://github.com/chelnak)) 110 | 111 | ## [v0.10.0](https://github.com/chelnak/gh-changelog/tree/v0.10.0) - 2022-10-14 112 | 113 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.9.0...v0.10.0) 114 | 115 | ### Added 116 | 117 | - Rename text logger [#105](https://github.com/chelnak/gh-changelog/pull/105) ([chelnak](https://github.com/chelnak)) 118 | - Better logging control [#102](https://github.com/chelnak/gh-changelog/pull/102) ([chelnak](https://github.com/chelnak)) 119 | - Refactor builder & changelog in to pkg [#101](https://github.com/chelnak/gh-changelog/pull/101) ([chelnak](https://github.com/chelnak)) 120 | - Scoped changelogs [#100](https://github.com/chelnak/gh-changelog/pull/100) ([chelnak](https://github.com/chelnak)) 121 | 122 | ## [v0.9.0](https://github.com/chelnak/gh-changelog/tree/v0.9.0) - 2022-10-07 123 | 124 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.8.1...v0.9.0) 125 | 126 | ### Added 127 | 128 | - Help and error UX improvements [#92](https://github.com/chelnak/gh-changelog/pull/92) ([chelnak](https://github.com/chelnak)) 129 | - Refactoring and code hygiene [#88](https://github.com/chelnak/gh-changelog/pull/88) ([chelnak](https://github.com/chelnak)) 130 | - Remove internal/pkg [#86](https://github.com/chelnak/gh-changelog/pull/86) ([chelnak](https://github.com/chelnak)) 131 | - Add a new excluded label default [#82](https://github.com/chelnak/gh-changelog/pull/82) ([chelnak](https://github.com/chelnak)) 132 | 133 | ### Fixed 134 | 135 | - Properly handle a repo with no tags [#95](https://github.com/chelnak/gh-changelog/pull/95) ([chelnak](https://github.com/chelnak)) 136 | 137 | ## [v0.8.1](https://github.com/chelnak/gh-changelog/tree/v0.8.1) - 2022-06-07 138 | 139 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.8.0...v0.8.1) 140 | 141 | ### Fixed 142 | 143 | - Fix lexer in PrintYAML method [#80](https://github.com/chelnak/gh-changelog/pull/80) ([chelnak](https://github.com/chelnak)) 144 | 145 | ## [v0.8.0](https://github.com/chelnak/gh-changelog/tree/v0.8.0) - 2022-05-20 146 | 147 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.7.0...v0.8.0) 148 | 149 | ### Added 150 | 151 | - Colorize config output [#77](https://github.com/chelnak/gh-changelog/pull/77) ([chelnak](https://github.com/chelnak)) 152 | - Simplify config printing [#70](https://github.com/chelnak/gh-changelog/pull/70) ([chelnak](https://github.com/chelnak)) 153 | - Add a command to view current config [#63](https://github.com/chelnak/gh-changelog/pull/63) ([chelnak](https://github.com/chelnak)) 154 | - Enable configuration from environment [#61](https://github.com/chelnak/gh-changelog/pull/61) ([chelnak](https://github.com/chelnak)) 155 | 156 | ### Fixed 157 | 158 | - Validate next version [#76](https://github.com/chelnak/gh-changelog/pull/76) ([chelnak](https://github.com/chelnak)) 159 | - Handle orphaned commits [#74](https://github.com/chelnak/gh-changelog/pull/74) ([chelnak](https://github.com/chelnak)) 160 | 161 | ## [v0.7.0](https://github.com/chelnak/gh-changelog/tree/v0.7.0) - 2022-05-14 162 | 163 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.6.1...v0.7.0) 164 | 165 | ### Added 166 | 167 | - Lowercase section names [#52](https://github.com/chelnak/gh-changelog/pull/52) ([chelnak](https://github.com/chelnak)) 168 | - Remove additional newlines in markdown [#51](https://github.com/chelnak/gh-changelog/pull/51) ([chelnak](https://github.com/chelnak)) 169 | - Rework configuration [#47](https://github.com/chelnak/gh-changelog/pull/47) ([chelnak](https://github.com/chelnak)) 170 | 171 | ### Fixed 172 | 173 | - Ensure that Keep a Changelog format is followed [#53](https://github.com/chelnak/gh-changelog/pull/53) ([chelnak](https://github.com/chelnak)) 174 | 175 | ## [v0.6.1](https://github.com/chelnak/gh-changelog/tree/v0.6.1) - 2022-05-08 176 | 177 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.6.0...v0.6.1) 178 | 179 | ### Fixed 180 | 181 | - Remove Println [#44](https://github.com/chelnak/gh-changelog/pull/44) ([chelnak](https://github.com/chelnak)) 182 | 183 | ## [v0.6.0](https://github.com/chelnak/gh-changelog/tree/v0.6.0) - 2022-05-08 184 | 185 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.5.0...v0.6.0) 186 | 187 | ### Added 188 | 189 | - Add update check [#41](https://github.com/chelnak/gh-changelog/pull/41) ([chelnak](https://github.com/chelnak)) 190 | - Break up changelog package [#38](https://github.com/chelnak/gh-changelog/pull/38) ([chelnak](https://github.com/chelnak)) 191 | 192 | ### Fixed 193 | 194 | - Fix error messages [#37](https://github.com/chelnak/gh-changelog/pull/37) ([chelnak](https://github.com/chelnak)) 195 | 196 | ## [v0.5.0](https://github.com/chelnak/gh-changelog/tree/v0.5.0) - 2022-05-07 197 | 198 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.4.0...v0.5.0) 199 | 200 | ### Added 201 | 202 | - Implementation of next-version and unreleased [#35](https://github.com/chelnak/gh-changelog/pull/35) ([chelnak](https://github.com/chelnak)) 203 | - Refactor & (some) tests [#34](https://github.com/chelnak/gh-changelog/pull/34) ([chelnak](https://github.com/chelnak)) 204 | 205 | ## [v0.4.0](https://github.com/chelnak/gh-changelog/tree/v0.4.0) - 2022-04-26 206 | 207 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.3.1...v0.4.0) 208 | 209 | ### Added 210 | 211 | - Migrate to GitHubs v4 API [#29](https://github.com/chelnak/gh-changelog/pull/29) ([chelnak](https://github.com/chelnak)) 212 | - Better config [#28](https://github.com/chelnak/gh-changelog/pull/28) ([chelnak](https://github.com/chelnak)) 213 | - Better config [#27](https://github.com/chelnak/gh-changelog/pull/27) ([chelnak](https://github.com/chelnak)) 214 | 215 | ### Fixed 216 | 217 | - Clarify functionality in README [#25](https://github.com/chelnak/gh-changelog/pull/25) ([chelnak](https://github.com/chelnak)) 218 | 219 | ## [v0.3.1](https://github.com/chelnak/gh-changelog/tree/v0.3.1) - 2022-04-20 220 | 221 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.3.0...v0.3.1) 222 | 223 | ### Fixed 224 | 225 | - Fixes root commit reference [#24](https://github.com/chelnak/gh-changelog/pull/24) ([chelnak](https://github.com/chelnak)) 226 | 227 | ## [v0.3.0](https://github.com/chelnak/gh-changelog/tree/v0.3.0) - 2022-04-20 228 | 229 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.2.1...v0.3.0) 230 | 231 | ### Added 232 | 233 | - Turbo boost! [#20](https://github.com/chelnak/gh-changelog/pull/20) ([chelnak](https://github.com/chelnak)) 234 | 235 | ### Fixed 236 | 237 | - Set longer line length for md render [#18](https://github.com/chelnak/gh-changelog/pull/18) ([chelnak](https://github.com/chelnak)) 238 | 239 | ## [v0.2.1](https://github.com/chelnak/gh-changelog/tree/v0.2.1) - 2022-04-18 240 | 241 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.2.0...v0.2.1) 242 | 243 | ### Fixed 244 | 245 | - Fix full changelog link [#14](https://github.com/chelnak/gh-changelog/pull/14) ([chelnak](https://github.com/chelnak)) 246 | 247 | ## [v0.2.0](https://github.com/chelnak/gh-changelog/tree/v0.2.0) - 2022-04-15 248 | 249 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.1.0...v0.2.0) 250 | 251 | ### Added 252 | 253 | - Add show command [#12](https://github.com/chelnak/gh-changelog/pull/12) ([chelnak](https://github.com/chelnak)) 254 | - Implement better errors [#9](https://github.com/chelnak/gh-changelog/pull/9) ([chelnak](https://github.com/chelnak)) 255 | 256 | ## [v0.1.0](https://github.com/chelnak/gh-changelog/tree/v0.1.0) - 2022-04-15 257 | 258 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/42d4c93b23eaf307c5f9712f4c62014fe38332bd...v0.1.0) 259 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Contributing to gh-changelog 5 | 6 | First off, thanks for taking the time to contribute! ❤️ 7 | 8 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 9 | 10 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 11 | 12 | > - Star the project 13 | > - Tweet about it 14 | > - Refer this project in your project's readme 15 | > - Mention the project at local meetups and tell your friends/colleagues 16 | 17 | 18 | ## Table of Contents 19 | 20 | - [I Have a Question](#i-have-a-question) 21 | - [I Want To Contribute](#i-want-to-contribute) 22 | - [Reporting Bugs](#reporting-bugs) 23 | - [Suggesting Enhancements](#suggesting-enhancements) 24 | - [Your First Code Contribution](#your-first-code-contribution) 25 | - [Improving The Documentation](#improving-the-documentation) 26 | - [Styleguides](#styleguides) 27 | - [Commit Messages](#commit-messages) 28 | - [Join The Project Team](#join-the-project-team) 29 | 30 | ## I Have a Question 31 | 32 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/chelnak/gh-changelog/blob/main/README.md). 33 | 34 | Before you ask a question, it is best to search for existing [Issues](https://github.com/chelnak/gh-changelog/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 35 | 36 | If you then still feel the need to ask a question and need clarification, we recommend the following: 37 | 38 | - Open an [Issue](https://github.com/chelnak/gh-changelog/issues/new). 39 | - Provide as much context as you can about what you're running into. 40 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 41 | 42 | We will then take care of the issue as soon as possible. 43 | 44 | 58 | 59 | ## I Want To Contribute 60 | 61 | > ### Legal Notice 62 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 63 | 64 | ### Reporting Bugs 65 | 66 | 67 | #### Before Submitting a Bug Report 68 | 69 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 70 | 71 | - Make sure that you are using the latest version. 72 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/chelnak/gh-changelog/blob/main/README.md). If you are looking for support, you might want to check [this section](#i-have-a-question)). 73 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/chelnak/gh-changelogissues?q=label%3Abug). 74 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 75 | - Collect information about the bug: 76 | - Stack trace (Traceback) 77 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 78 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 79 | - Possibly your input and the output 80 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 81 | 82 | 83 | #### How Do I Submit a Good Bug Report? 84 | 85 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to <>. 86 | 87 | 88 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 89 | 90 | - Open an [Issue](https://github.com/chelnak/gh-changelog/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 91 | - Explain the behavior you would expect and the actual behavior. 92 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 93 | - Provide the information you collected in the previous section. 94 | 95 | Once it's filed: 96 | 97 | - The project team will label the issue accordingly. 98 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 99 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 100 | 101 | 102 | 103 | ### Suggesting Enhancements 104 | 105 | This section guides you through submitting an enhancement suggestion for gh-changelog, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 106 | 107 | 108 | #### Before Submitting an Enhancement 109 | 110 | - Make sure that you are using the latest version. 111 | - Read the [documentation](https://github.com/chelnak/gh-changelog/blob/main/README.md) carefully and find out if the functionality is already covered, maybe by an individual configuration. 112 | - Perform a [search](https://github.com/chelnak/gh-changelog/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 113 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 114 | 115 | 116 | #### How Do I Submit a Good Enhancement Suggestion? 117 | 118 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/chelnak/gh-changelog/issues). 119 | 120 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 121 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 122 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 123 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 124 | - **Explain why this enhancement would be useful** to most gh-changelog users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 125 | 126 | 127 | 128 | 129 | ## Attribution 130 | 131 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Craig Gumbley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | tag: 2 | @git tag -a $(version) -m "Release $(version)" 3 | @git push --follow-tags 4 | 5 | lint: 6 | @golangci-lint run ./... 7 | 8 | build: 9 | @WORKINGDIR=$(pwd) goreleaser build --snapshot --rm-dist --single-target 10 | 11 | .PHONY: mocks 12 | mocks: 13 | @mockery --all 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Make your changelogs ✨ 2 | 3 | [![ci](https://github.com/chelnak/gh-changelog/actions/workflows/ci.yml/badge.svg)](https://github.com/chelnak/gh-changelog/actions/workflows/ci.yml) 4 | [![Release](https://img.shields.io/github/release/chelnak/gh-changelog.svg)](https://github.com/chelnak/gh-changelog/releases/latest) 5 | 6 | An opinionated [GitHub Cli](https://github.com/cli/cli) extension for creating changelogs that adhere to the [keep a changelog](https://keepachangelog.com/en/1.0.0/) specification. 7 | 8 | ## What is supported? 9 | 10 | `gh-changelog` is the tool for you if: 11 | 12 | - You want to closely follow the [keep a changelog](https://keepachangelog.com/en/1.0.0/) specification 13 | - You are using tags to mark releases 14 | - You are following a pull-request workflow 15 | 16 | ## Installation and Usage 17 | 18 | Before you start make sure that: 19 | 20 | - GitHub Cli is [installed](https://cli.github.com/manual/installation) and [authenticated](https://cli.github.com/manual/gh_auth_login) 21 | - You are inside a git repository 22 | - The repository contains commits and has been pushed to GitHub 23 | 24 | ### Install 25 | 26 | ```bash 27 | gh extension install chelnak/gh-changelog 28 | ``` 29 | 30 | ### Upgrade 31 | 32 | ```bash 33 | gh extension upgrade chelnak/gh-changelog 34 | ``` 35 | 36 | ### Create a changelog 37 | 38 | Creating changelog is simple. 39 | Once you have installed the extension just run: 40 | 41 | ```bash 42 | gh changelog new 43 | ``` 44 | 45 | There are also a few useful flags available. 46 | 47 | #### --next-version 48 | 49 | Allows you to specify the next version of your project if it has not been tagged. 50 | 51 | ```bash 52 | gh changelog new --next-version v1.2.0 53 | ``` 54 | 55 | #### --from-version 56 | 57 | Allows you to specify the version to start generating the changelog from. 58 | 59 | ```bash 60 | gh changelog new --from-version v1.0.0 61 | ``` 62 | 63 | #### --latest 64 | 65 | Creates a changelog that includes only the latest release. 66 | This option can work well with `--next-version`. 67 | 68 | ```bash 69 | gh changelog new --latest 70 | ``` 71 | 72 | #### Console output 73 | 74 | You can switch between two `spinner` and `console`. 75 | 76 | The default (`spinner`) can be overriden with the `--logger` flag. 77 | 78 | ```bash 79 | gh changelog new --logger console 80 | ``` 81 | 82 | #### Behaviour in CI environments 83 | 84 | If the extension detects that it is being ran in a CI environment, it will automatically switch to `console` logging mode. 85 | This behaviour can be prevented by passing the flag `--logger spinner`. 86 | 87 | ### View your changelog 88 | 89 | You can view your changelog by running: 90 | 91 | ```bash 92 | gh changelog show 93 | ``` 94 | 95 | The `show` command renders the changelog in your terminal. 96 | 97 | ### Configuration 98 | 99 | Configuration for `gh changelog` can be found at `~/.config/gh-changelog/config.yaml`. 100 | 101 | You can also view the configuration by running: 102 | 103 | ```bash 104 | gh changelog config 105 | ``` 106 | 107 | To print out JSON instead of YAML use `--output json` flag. 108 | 109 | ```bash 110 | gh changelog config --output json 111 | ``` 112 | 113 | Some sensible defaults are provided to help you get off to a flying start. 114 | 115 | ```yaml 116 | # Labels added here will be ommitted from the changelog 117 | excluded_labels: 118 | - maintenance 119 | # This is the filename of the generated changelog 120 | file_name: CHANGELOG.md 121 | # This is where labels are mapped to the sections in a changelog entry 122 | # The possible sections are restricted to: Added, Changed, Deprecated, 123 | # Removed, Fixed, Security. 124 | sections: 125 | changed: 126 | - backwards-incompatible 127 | added: 128 | - fixed 129 | - enhancement 130 | fixed: 131 | - bug 132 | - bugfix 133 | - documentation 134 | # When set to true, unlabelled entries will not be included in the changelog. 135 | # By default they will be grouped in a section named "Other". 136 | skip_entries_without_label: false 137 | # Adds an unreleased section to the changelog. This will contain any qualifying entries 138 | # that have been added since the last tag. 139 | # Note: The unreleased section is not created when the --next-version flag is used. 140 | show_unreleased: true 141 | # If set to false, the tool will not check remotely for updates 142 | check_for_updates: true 143 | # Determines the logging mode. The default is spinner. The other option is console. 144 | logger: spinner 145 | ``` 146 | 147 | You can also override any setting using environment variables. When configured from the environment, 148 | properties are prefixed with `CHANGELOG`. 149 | For example, overriding `check_for_updates` might look 150 | something like this: 151 | 152 | ```bash 153 | export CHANGELOG_CHECK_FOR_UPDATES=false 154 | ``` 155 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | // Package cmd holds all top-level cobra commands. Each file should contain 2 | // only one command and that command should have only one purpose. 3 | package cmd 4 | 5 | import ( 6 | "errors" 7 | "os" 8 | 9 | "github.com/chelnak/gh-changelog/internal/configuration" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var ( 14 | output string 15 | noColor bool 16 | ) 17 | 18 | // configCmd is the entry point for printing the applications configuration in the terminal 19 | var configCmd = &cobra.Command{ 20 | Use: "config", 21 | Short: "Prints the current configuration to the terminal in either json or yaml format. Defaults to yaml.", 22 | Long: "Prints the current configuration to the terminal in either json or yaml format. Defaults to yaml.", 23 | RunE: func(command *cobra.Command, args []string) error { 24 | switch output { 25 | case "json": 26 | return configuration.Config.PrintJSON(noColor, os.Stdout) 27 | case "yaml": 28 | return configuration.Config.PrintYAML(noColor, os.Stdout) 29 | default: 30 | return errors.New("invalid output format. Valid values are 'json' and 'yaml'") 31 | } 32 | }, 33 | } 34 | 35 | func init() { 36 | configCmd.Flags().StringVarP(&output, "output", "o", "yaml", "The output format. Valid values are 'json' and 'yaml'. Defaults to 'yaml'.") 37 | configCmd.Flags().BoolVarP(&noColor, "no-color", "n", false, "Disable color output") 38 | } 39 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | // Package cmd holds all top-level cobra commands. Each file should contain 2 | // only one command and that command should have only one purpose. 3 | package cmd 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | 9 | "github.com/chelnak/gh-changelog/internal/configuration" 10 | "github.com/chelnak/gh-changelog/internal/get" 11 | "github.com/chelnak/gh-changelog/internal/writer" 12 | "github.com/chelnak/gh-changelog/pkg/changelog" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | type outputEnum string 17 | 18 | const ( 19 | outputStandard outputEnum = "standard" 20 | outputNotes outputEnum = "notes" 21 | ) 22 | 23 | func (e *outputEnum) String() string { 24 | return string(*e) 25 | } 26 | 27 | func (e *outputEnum) Set(v string) error { 28 | switch v { 29 | case string(outputStandard), string(outputNotes): 30 | *e = outputEnum(v) 31 | return nil 32 | default: 33 | return fmt.Errorf(`must be one of %s or %s`, outputStandard, outputNotes) 34 | } 35 | } 36 | 37 | func (e *outputEnum) Type() string { 38 | return "outputEnum" 39 | } 40 | 41 | var outputTemplate = outputStandard 42 | var printLatest bool 43 | var printVersion string 44 | 45 | // getCmd retrieves a local changelog and prints it to stdout 46 | var getCmd = &cobra.Command{ 47 | Use: "get", 48 | Short: "Reads a changelog file and prints the result to stdout", 49 | Long: `Reads a changelog file and prints the result to stdout. 50 | 51 | This command is useful for creating and updating Release notes in GitHub. 52 | 53 | ┌─────────────────────────────────────────────────────────────────────┐ 54 | │Example │ 55 | ├─────────────────────────────────────────────────────────────────────┤ 56 | │ │ 57 | │→ gh changelog get --latest > release_notes.md │ 58 | │→ gh release create --title "Release v1.0.0" -F release_notes.md │ 59 | │ │ 60 | └─────────────────────────────────────────────────────────────────────┘ 61 | `, 62 | RunE: func(command *cobra.Command, args []string) error { 63 | fileName := configuration.Config.FileName 64 | 65 | var tmplSrc string 66 | var changelog changelog.Changelog 67 | var err error 68 | 69 | if printLatest { 70 | changelog, err = get.GetLatest(fileName) 71 | } else if printVersion != "" { 72 | changelog, err = get.GetVersion(fileName, printVersion) 73 | } else if outputTemplate == outputNotes { 74 | err = fmt.Errorf("notes output only supported with latest or version") 75 | } else { 76 | changelog, err = get.GetAll(fileName) 77 | } 78 | 79 | switch outputTemplate { 80 | case outputStandard: 81 | tmplSrc = writer.TmplSrcStandard 82 | case outputNotes: 83 | tmplSrc = writer.TmplSrcNotes 84 | } 85 | 86 | if err != nil { 87 | return err 88 | } 89 | 90 | var buf bytes.Buffer 91 | if err := writer.Write(&buf, tmplSrc, changelog); err != nil { 92 | return err 93 | } 94 | 95 | fmt.Println(buf.String()) 96 | 97 | return nil 98 | }, 99 | } 100 | 101 | func init() { 102 | getCmd.Flags().BoolVar( 103 | &printLatest, 104 | "latest", 105 | false, 106 | "Prints the latest version from the changelog to stdout.", 107 | ) 108 | 109 | getCmd.Flags().StringVar( 110 | &printVersion, 111 | "version", 112 | "", 113 | "Prints a specific version from the changelog to stdout.", 114 | ) 115 | 116 | getCmd.Flags().Var( 117 | &outputTemplate, 118 | "output", 119 | fmt.Sprintf(`Output template. allowed: "%s" or "%s"`, outputStandard, outputNotes), 120 | ) 121 | 122 | getCmd.Flags().SortFlags = false 123 | } 124 | -------------------------------------------------------------------------------- /cmd/new.go: -------------------------------------------------------------------------------- 1 | // Package cmd holds all top-level cobra commands. Each file should contain 2 | // only one command and that command should have only one purpose. 3 | package cmd 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/chelnak/gh-changelog/internal/configuration" 10 | "github.com/chelnak/gh-changelog/internal/writer" 11 | "github.com/chelnak/gh-changelog/pkg/builder" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var nextVersion string 16 | var fromVersion string 17 | var latestVersion bool 18 | var logger string 19 | 20 | // newCmd is the entry point for creating a new changelog 21 | var newCmd = &cobra.Command{ 22 | Use: "new", 23 | Short: "Creates a new changelog from activity in the current repository", 24 | Long: "Creates a new changelog from activity in the current repository.", 25 | RunE: func(command *cobra.Command, args []string) error { 26 | opts := builder.BuilderOptions{ 27 | Logger: logger, 28 | NextVersion: nextVersion, 29 | FromVersion: fromVersion, 30 | LatestVersion: latestVersion, 31 | } 32 | 33 | builder, err := builder.NewBuilder(opts) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | changelog, err := builder.BuildChangelog() 39 | if err != nil { 40 | return err 41 | } 42 | 43 | f, err := os.Create(filepath.Clean(configuration.Config.FileName)) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if err := writer.Write(f, writer.TmplSrcStandard, changelog); err != nil { 49 | return err 50 | } 51 | 52 | return nil 53 | }, 54 | } 55 | 56 | func init() { 57 | newCmd.Flags().StringVar(&nextVersion, "next-version", "", "The next version to be released. The value passed does not have to be an existing tag.") 58 | 59 | newCmd.Flags().StringVar( 60 | &fromVersion, 61 | "from-version", 62 | "", 63 | "The version from which to start the changelog. If the value passed does not exist as a tag,\nthe changelog will be built from the first tag.", 64 | ) 65 | 66 | newCmd.Flags().BoolVar( 67 | &latestVersion, 68 | "latest", 69 | false, 70 | "Build the changelog starting from the latest tag. Using this flag will result in a changelog with one entry.\nIt can be useful for generating a changelog to be used in release notes.", 71 | ) 72 | 73 | newCmd.Flags().StringVar(&logger, "logger", "", "The type of logger to use. Valid values are 'spinner' and 'console'. The default is 'spinner'.") 74 | 75 | newCmd.MarkFlagsMutuallyExclusive("from-version", "latest") 76 | newCmd.Flags().SortFlags = false 77 | } 78 | -------------------------------------------------------------------------------- /cmd/parse.go: -------------------------------------------------------------------------------- 1 | // Package cmd holds all top-level cobra commands. Each file should contain 2 | // only one command and that command should have only one purpose. 3 | package cmd 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/chelnak/gh-changelog/internal/configuration" 9 | "github.com/chelnak/gh-changelog/pkg/parser" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // parseCmd allows a user to parse a markdown changelog in to a struct. 14 | // This is currently a work in progress and may go away in the future. 15 | var parseCmd = &cobra.Command{ 16 | Use: "parse", 17 | Short: "EXPERIMENTAL: Parse a changelog file in to a Changelog struct", 18 | Long: "EXPERIMENTAL: Parse a changelog file in to a Changelog struct", 19 | Hidden: true, 20 | RunE: func(command *cobra.Command, args []string) error { 21 | changelog := configuration.Config.FileName 22 | 23 | parser := parser.NewParser(changelog, "", "") 24 | cl, err := parser.Parse() 25 | if err != nil { 26 | return err 27 | } 28 | 29 | // As an example, print out the changelog that was parsed. 30 | fmt.Printf("owner: %s\n", cl.GetRepoOwner()) 31 | fmt.Printf("name: %s\n", cl.GetRepoName()) 32 | fmt.Println("tags:") 33 | for _, e := range cl.GetEntries() { 34 | fmt.Printf(" %s\n", e.Tag) 35 | } 36 | 37 | return nil 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package cmd holds all top-level cobra commands. Each file should contain 2 | // only one command and that command should have only one purpose. 3 | package cmd 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/chelnak/gh-changelog/internal/configuration" 11 | "github.com/chelnak/gh-changelog/internal/utils" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var version = "dev" 16 | var errSilent = errors.New("ErrSilent") 17 | 18 | // rootCmd represents the base command when called without any subcommands 19 | var rootCmd = &cobra.Command{ 20 | Use: "changelog [command]", 21 | Short: "A GitHub CLI extension that will make your changelogs ✨", 22 | Long: `gh changelog - A GitHub CLI extension that will make your changelogs ✨ 23 | 24 | Easily create standardised changelogs for your project that follow 25 | conventions set by the keepachangelog project. 26 | 27 | For more information check out the following link: 28 | 29 | 🔗 https://keepachangelog.com 30 | 31 | Getting started is easy: 32 | 33 | ┌────────────────────┐ 34 | │••• │ 35 | ├────────────────────┤ 36 | │ │ 37 | │→ gh changelog new │ 38 | └────────────────────┘ 39 | 40 | You can also view the changelog at any time: 41 | 42 | ┌────────────────────┐ 43 | │••• │ 44 | ├────────────────────┤ 45 | │ │ 46 | │→ gh changelog show │ 47 | └────────────────────┘ 48 | 49 | Issues or feature requests can be opened at: 50 | 51 | 🔗 https://github.com/chelnak/gh-changelog/issues`, 52 | Version: version, 53 | SilenceUsage: true, 54 | SilenceErrors: true, 55 | Run: nil, 56 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 57 | if configuration.Config.CheckForUpdates { 58 | utils.CheckForUpdate(version) 59 | } 60 | }, 61 | } 62 | 63 | func init() { 64 | err := configuration.InitConfig() 65 | if err != nil { 66 | formatError(err) 67 | os.Exit(1) 68 | } 69 | 70 | rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { 71 | cmd.Println(err) 72 | cmd.Println(cmd.UsageString()) 73 | return errSilent 74 | }) 75 | 76 | rootCmd.AddCommand(newCmd) 77 | rootCmd.AddCommand(showCmd) 78 | rootCmd.AddCommand(getCmd) 79 | rootCmd.AddCommand(configCmd) 80 | rootCmd.AddCommand(parseCmd) 81 | } 82 | 83 | func formatError(err error) { 84 | fmt.Print("\n❌ It looks like something went wrong!\n") 85 | fmt.Println("\nReported errors:") 86 | fmt.Fprintln(os.Stderr, fmt.Errorf("• %s", err)) 87 | fmt.Println() 88 | } 89 | 90 | // Execute is called from main and is responsible for processing 91 | // requests to the application and handling exit codes appropriately 92 | func Execute() int { 93 | if err := rootCmd.Execute(); err != nil { 94 | if err != errSilent { 95 | formatError(err) 96 | } 97 | return 1 98 | } 99 | return 0 100 | } 101 | -------------------------------------------------------------------------------- /cmd/show.go: -------------------------------------------------------------------------------- 1 | // Package cmd holds all top-level cobra commands. Each file should contain 2 | // only one command and that command should have only one purpose. 3 | package cmd 4 | 5 | import ( 6 | "github.com/chelnak/gh-changelog/internal/configuration" 7 | "github.com/chelnak/gh-changelog/internal/show" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // showCmd is the entry point for rendering a changelog in the terminal 12 | var showCmd = &cobra.Command{ 13 | Use: "show", 14 | Short: "Renders the current changelog in the terminal", 15 | Long: "Renders the current changelog in the terminal", 16 | RunE: func(command *cobra.Command, args []string) error { 17 | changelog := configuration.Config.FileName 18 | return show.Render(changelog) 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chelnak/gh-changelog 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.2.1 7 | github.com/alecthomas/chroma v0.10.0 8 | github.com/charmbracelet/bubbles v0.18.0 9 | github.com/charmbracelet/bubbletea v0.26.1 10 | github.com/charmbracelet/glamour v0.7.0 11 | github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c 12 | github.com/chelnak/ysmrr v0.4.0 13 | github.com/cli/go-gh/v2 v2.9.0 14 | github.com/fatih/color v1.16.0 15 | github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 16 | github.com/jarcoal/httpmock v1.2.0 17 | github.com/rs/zerolog v1.32.0 18 | github.com/shurcooL/githubv4 v0.0.0-20240429030203-be2daab69064 19 | github.com/spf13/cobra v1.8.0 20 | github.com/spf13/viper v1.18.2 21 | github.com/stretchr/testify v1.9.0 22 | golang.org/x/text v0.15.0 23 | gopkg.in/h2non/gock.v1 v1.1.2 24 | gopkg.in/yaml.v2 v2.4.0 25 | ) 26 | 27 | require ( 28 | github.com/alecthomas/chroma/v2 v2.13.0 // indirect 29 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 30 | github.com/aymerick/douceur v0.2.0 // indirect 31 | github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 // indirect 32 | github.com/cli/safeexec v1.0.1 // indirect 33 | github.com/cli/shurcooL-graphql v0.0.4 // indirect 34 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 35 | github.com/dlclark/regexp2 v1.11.0 // indirect 36 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 37 | github.com/fsnotify/fsnotify v1.7.0 // indirect 38 | github.com/gorilla/css v1.0.1 // indirect 39 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect 40 | github.com/hashicorp/hcl v1.0.0 // indirect 41 | github.com/henvic/httpretty v0.1.3 // indirect 42 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 43 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 44 | github.com/magiconair/properties v1.8.7 // indirect 45 | github.com/mattn/go-colorable v0.1.13 // indirect 46 | github.com/mattn/go-isatty v0.0.20 // indirect 47 | github.com/mattn/go-localereader v0.0.1 // indirect 48 | github.com/mattn/go-runewidth v0.0.15 // indirect 49 | github.com/microcosm-cc/bluemonday v1.0.26 // indirect 50 | github.com/mitchellh/mapstructure v1.5.0 // indirect 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 52 | github.com/muesli/cancelreader v0.2.2 // indirect 53 | github.com/muesli/reflow v0.3.0 // indirect 54 | github.com/muesli/termenv v0.15.2 // indirect 55 | github.com/olekukonko/tablewriter v0.0.5 // indirect 56 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 57 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 58 | github.com/rivo/uniseg v0.4.7 // indirect 59 | github.com/sagikazarmark/locafero v0.4.0 // indirect 60 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 61 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect 62 | github.com/sourcegraph/conc v0.3.0 // indirect 63 | github.com/spf13/afero v1.11.0 // indirect 64 | github.com/spf13/cast v1.6.0 // indirect 65 | github.com/spf13/pflag v1.0.5 // indirect 66 | github.com/stretchr/objx v0.5.2 // indirect 67 | github.com/subosito/gotenv v1.6.0 // indirect 68 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect 69 | github.com/yuin/goldmark v1.7.1 // indirect 70 | github.com/yuin/goldmark-emoji v1.0.2 // indirect 71 | go.uber.org/multierr v1.11.0 // indirect 72 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 73 | golang.org/x/net v0.25.0 // indirect 74 | golang.org/x/sync v0.7.0 // indirect 75 | golang.org/x/sys v0.20.0 // indirect 76 | golang.org/x/term v0.20.0 // indirect 77 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 78 | gopkg.in/ini.v1 v1.67.0 // indirect 79 | gopkg.in/yaml.v3 v3.0.1 // indirect 80 | ) 81 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 4 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 5 | github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= 6 | github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 7 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 8 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 9 | github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI= 10 | github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= 11 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 12 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 14 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 15 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 16 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 17 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 18 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 19 | github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0= 20 | github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo= 21 | github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng= 22 | github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps= 23 | github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c h1:0FwZb0wTiyalb8QQlILWyIuh3nF5wok6j9D9oUQwfQY= 24 | github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs= 25 | github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA= 26 | github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c= 27 | github.com/chelnak/ysmrr v0.4.0 h1:WMvLGPlBK0kb6wHf5z9FfNvpM6sB9765jy2ajYc1Sfs= 28 | github.com/chelnak/ysmrr v0.4.0/go.mod h1:8vCna4PJsPCb6eevtoG7Tljzfx3twpsO203Qj2gafLM= 29 | github.com/cli/go-gh/v2 v2.9.0 h1:D3lTjEneMYl54M+WjZ+kRPrR5CEJ5BHS05isBPOV3LI= 30 | github.com/cli/go-gh/v2 v2.9.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE= 31 | github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= 32 | github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= 33 | github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= 34 | github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= 35 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 36 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 37 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 40 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 42 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 43 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 44 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 45 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 46 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 47 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 48 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 49 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 50 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 51 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 52 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 53 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 54 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 55 | github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 h1:yEt5djSYb4iNtmV9iJGVday+i4e9u6Mrn5iP64HH5QM= 56 | github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 57 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 58 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 59 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 60 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 61 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= 62 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 63 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 64 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 65 | github.com/henvic/httpretty v0.1.3 h1:4A6vigjz6Q/+yAfTD4wqipCv+Px69C7Th/NhT0ApuU8= 66 | github.com/henvic/httpretty v0.1.3/go.mod h1:UUEv7c2kHZ5SPQ51uS3wBpzPDibg2U3Y+IaXyHy5GBg= 67 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 68 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 69 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 70 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 71 | github.com/jarcoal/httpmock v1.2.0 h1:gSvTxxFR/MEMfsGrvRbdfpRUMBStovlSRLw0Ep1bwwc= 72 | github.com/jarcoal/httpmock v1.2.0/go.mod h1:oCoTsnAz4+UoOUIf5lJOWV2QQIW5UoeUI6aM2YnWAZk= 73 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 74 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 75 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 76 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 77 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 78 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 79 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 80 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 81 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 82 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 83 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 84 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 85 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 86 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 87 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 88 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 89 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 90 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 91 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 92 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 93 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 94 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 95 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 96 | github.com/maxatome/go-testdeep v1.11.0 h1:Tgh5efyCYyJFGUYiT0qxBSIDeXw0F5zSoatlou685kk= 97 | github.com/maxatome/go-testdeep v1.11.0/go.mod h1:011SgQ6efzZYAen6fDn4BqQ+lUR72ysdyKe7Dyogw70= 98 | github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= 99 | github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= 100 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 101 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 102 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 103 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 104 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 105 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 106 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 107 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 108 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 109 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 110 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= 111 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 112 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 113 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 114 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 115 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 116 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 117 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 118 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 119 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 120 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 121 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 122 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 123 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 124 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 125 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 126 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 127 | github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= 128 | github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 129 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 130 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 131 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 132 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 133 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 134 | github.com/shurcooL/githubv4 v0.0.0-20240429030203-be2daab69064 h1:RCQBSFx5JrsbHltqTtJ+kN3U0Y3a/N/GlVdmRSoxzyE= 135 | github.com/shurcooL/githubv4 v0.0.0-20240429030203-be2daab69064/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= 136 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= 137 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 138 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 139 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 140 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 141 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 142 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 143 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 144 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 145 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 146 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 147 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 148 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= 149 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 150 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 151 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 152 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 153 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 154 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 155 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 156 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 157 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 158 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 159 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 160 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 161 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 162 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 163 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= 164 | github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= 165 | github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 166 | github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= 167 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 168 | github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= 169 | github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= 170 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 171 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 172 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 173 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 174 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 175 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 176 | golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= 177 | golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= 178 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 179 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 180 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 181 | golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 183 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 184 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 185 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 186 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 187 | golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= 188 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 189 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 190 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 191 | golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= 192 | golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 193 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 194 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 195 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 196 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 197 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 198 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 199 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 200 | gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= 201 | gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= 202 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 203 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 204 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 205 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 206 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 207 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 208 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 209 | -------------------------------------------------------------------------------- /internal/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | // Package configuration contains a number of methods that are used 2 | // to provide configuration to the wider application. It uses viper 3 | // to pull config from either the environment or a config file then 4 | // unmarhsals the config into the configuration struct. The configuration struct 5 | // is made available to the application via a package level variable 6 | // called Config. 7 | package configuration 8 | 9 | import ( 10 | "encoding/json" 11 | "fmt" 12 | "io" 13 | "os" 14 | "path/filepath" 15 | 16 | "github.com/alecthomas/chroma" 17 | "github.com/alecthomas/chroma/formatters" 18 | "github.com/alecthomas/chroma/lexers" 19 | "github.com/alecthomas/chroma/styles" 20 | "github.com/spf13/viper" 21 | "gopkg.in/yaml.v2" 22 | ) 23 | 24 | var Config configuration 25 | 26 | type configuration struct { 27 | FileName string `mapstructure:"file_name" yaml:"file_name" json:"fileName"` 28 | ExcludedLabels []string `mapstructure:"excluded_labels" yaml:"excluded_labels" json:"excludedLabels"` 29 | Sections map[string][]string `mapstructure:"sections" yaml:"sections" json:"sections"` 30 | SkipEntriesWithoutLabel bool `mapstructure:"skip_entries_without_label" yaml:"skip_entries_without_label" json:"skipEntriesWithoutLabel"` 31 | ShowUnreleased bool `mapstructure:"show_unreleased" yaml:"show_unreleased" json:"showUnreleased"` 32 | CheckForUpdates bool `mapstructure:"check_for_updates" yaml:"check_for_updates" json:"checkForUpdates"` 33 | Logger string `mapstructure:"logger" yaml:"logger" json:"logger"` 34 | } 35 | 36 | type writeOptions struct { 37 | data string 38 | lexerName string 39 | noColor bool 40 | writer io.Writer 41 | } 42 | 43 | func prettyWrite(opts writeOptions) error { 44 | lexer := lexers.Get(opts.lexerName) 45 | if lexer == nil { 46 | lexer = lexers.Fallback 47 | } 48 | 49 | lexer = chroma.Coalesce(lexer) 50 | 51 | style := styles.Get("native") 52 | if style == nil { 53 | style = styles.Fallback 54 | } 55 | 56 | formatter := formatters.Get("terminal16m") 57 | 58 | if opts.noColor { 59 | formatter = formatters.Get("noop") 60 | } 61 | 62 | iterator, err := lexer.Tokenise(nil, opts.data) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return formatter.Format(opts.writer, style, iterator) 68 | } 69 | 70 | func (c *configuration) PrintJSON(noColor bool, writer io.Writer) error { 71 | b, err := json.MarshalIndent(c, "", " ") 72 | b = append(b, '\n') 73 | if err != nil { 74 | return err 75 | } 76 | 77 | opts := writeOptions{ 78 | data: string(b), 79 | lexerName: "json", 80 | noColor: noColor, 81 | writer: writer, 82 | } 83 | 84 | return prettyWrite(opts) 85 | } 86 | 87 | func (c *configuration) PrintYAML(noColor bool, writer io.Writer) error { 88 | b, err := yaml.Marshal(c) 89 | y := []byte("---\n") 90 | y = append(y, b...) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | opts := writeOptions{ 96 | data: string(y), 97 | lexerName: "yaml", 98 | noColor: noColor, 99 | writer: writer, 100 | } 101 | 102 | return prettyWrite(opts) 103 | } 104 | 105 | func InitConfig() error { 106 | home, _ := os.UserHomeDir() 107 | file := ".changelog" 108 | extension := "yml" 109 | 110 | viper.SetConfigName(file) 111 | viper.SetConfigType(extension) 112 | 113 | cfgPath := filepath.Join(home, ".config", "gh-changelog") 114 | if _, err := os.Stat(cfgPath); os.IsNotExist(err) { 115 | if err := os.MkdirAll(cfgPath, 0750); err != nil { 116 | return fmt.Errorf("failed to create config directory: %s", err) 117 | } 118 | } 119 | 120 | // Viper search paths in order of precedence 121 | viper.AddConfigPath(".") 122 | viper.AddConfigPath(cfgPath) 123 | 124 | setDefaults() 125 | 126 | if err := viper.ReadInConfig(); err != nil { 127 | defaultConfigPath := filepath.Join(cfgPath, fmt.Sprintf("%s.%s", file, extension)) 128 | err := viper.SafeWriteConfigAs(defaultConfigPath) 129 | if err != nil { 130 | return fmt.Errorf("failed to write config: %s", err) 131 | } 132 | } 133 | 134 | viper.AutomaticEnv() 135 | viper.SetEnvPrefix("changelog") 136 | 137 | err := viper.Unmarshal(&Config) 138 | if err != nil { 139 | return fmt.Errorf("failed to parse config: %s", err) 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func setDefaults() { 146 | viper.SetDefault("file_name", "CHANGELOG.md") 147 | viper.SetDefault("excluded_labels", []string{"maintenance", "dependencies"}) 148 | 149 | sections := make(map[string][]string) 150 | sections["changed"] = []string{"backwards-incompatible"} 151 | sections["added"] = []string{"feature", "enhancement"} 152 | sections["fixed"] = []string{"bug", "bugfix", "documentation"} 153 | 154 | viper.SetDefault("sections", sections) 155 | 156 | viper.SetDefault("skip_entries_without_label", false) 157 | 158 | viper.SetDefault("show_unreleased", true) 159 | 160 | viper.SetDefault("check_for_updates", true) 161 | 162 | viper.SetDefault("no_color", false) 163 | 164 | viper.SetDefault("logger", "spinner") 165 | } 166 | -------------------------------------------------------------------------------- /internal/configuration/configuration_test.go: -------------------------------------------------------------------------------- 1 | package configuration_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/chelnak/gh-changelog/internal/configuration" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestInitConfigSetsCorrectValues(t *testing.T) { 12 | err := configuration.InitConfig() 13 | assert.NoError(t, err) 14 | 15 | config := configuration.Config 16 | 17 | assert.Equal(t, "CHANGELOG.md", config.FileName) 18 | 19 | assert.Equal(t, []string{"maintenance", "dependencies"}, config.ExcludedLabels) 20 | assert.Equal(t, 2, len(config.ExcludedLabels)) 21 | 22 | assert.True(t, containsKey(config.Sections, "changed")) 23 | assert.True(t, containsKey(config.Sections, "added")) 24 | assert.True(t, containsKey(config.Sections, "fixed")) 25 | 26 | assert.Equal(t, 3, len(config.Sections)) 27 | assert.Equal(t, false, config.SkipEntriesWithoutLabel) 28 | assert.Equal(t, true, config.ShowUnreleased) 29 | assert.Equal(t, true, config.CheckForUpdates) 30 | assert.Equal(t, "spinner", config.Logger) 31 | } 32 | 33 | func TestPrintJSON(t *testing.T) { 34 | err := configuration.InitConfig() 35 | assert.NoError(t, err) 36 | 37 | config := configuration.Config 38 | 39 | var buf bytes.Buffer 40 | err = config.PrintJSON(true, &buf) 41 | assert.NoError(t, err) 42 | 43 | cfg := `{ 44 | "fileName": "CHANGELOG.md", 45 | "excludedLabels": [ 46 | "maintenance", 47 | "dependencies" 48 | ], 49 | "sections": { 50 | "added": [ 51 | "feature", 52 | "enhancement" 53 | ], 54 | "changed": [ 55 | "backwards-incompatible" 56 | ], 57 | "fixed": [ 58 | "bug", 59 | "bugfix", 60 | "documentation" 61 | ] 62 | }, 63 | "skipEntriesWithoutLabel": false, 64 | "showUnreleased": true, 65 | "checkForUpdates": true, 66 | "logger": "spinner" 67 | } 68 | ` 69 | 70 | assert.Equal(t, cfg, buf.String()) 71 | } 72 | 73 | func TestPrintYAML(t *testing.T) { 74 | err := configuration.InitConfig() 75 | assert.NoError(t, err) 76 | 77 | config := configuration.Config 78 | 79 | var buf bytes.Buffer 80 | err = config.PrintYAML(true, &buf) 81 | assert.NoError(t, err) 82 | 83 | cfg := `--- 84 | file_name: CHANGELOG.md 85 | excluded_labels: 86 | - maintenance 87 | - dependencies 88 | sections: 89 | added: 90 | - feature 91 | - enhancement 92 | changed: 93 | - backwards-incompatible 94 | fixed: 95 | - bug 96 | - bugfix 97 | - documentation 98 | skip_entries_without_label: false 99 | show_unreleased: true 100 | check_for_updates: true 101 | logger: spinner 102 | ` 103 | assert.Equal(t, cfg, buf.String()) 104 | } 105 | 106 | func containsKey(m map[string][]string, key string) bool { 107 | _, ok := m[key] 108 | return ok 109 | } 110 | -------------------------------------------------------------------------------- /internal/environment/environment.go: -------------------------------------------------------------------------------- 1 | // Package environment provides helper methods for working with the 2 | // current environment. 3 | package environment 4 | 5 | import "os" 6 | 7 | // IsCI returns true if the CI environment variable is set to true. 8 | // This is used for most CI systems. 9 | func IsCI() bool { 10 | return os.Getenv("CI") == "true" 11 | } 12 | -------------------------------------------------------------------------------- /internal/environment/environment_test.go: -------------------------------------------------------------------------------- 1 | package environment_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/chelnak/gh-changelog/internal/environment" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestIsCIReturnsTrueWhenRunningInCI(t *testing.T) { 12 | _ = os.Setenv("CI", "true") 13 | defer func() { 14 | _ = os.Unsetenv("CI") 15 | }() 16 | assert.True(t, environment.IsCI()) 17 | } 18 | -------------------------------------------------------------------------------- /internal/get/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). 7 | 8 | ## [v0.13.1](https://github.com/chelnak/gh-changelog/tree/v0.13.1) - 2023-04-25 9 | 10 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.13.0...v0.13.1) 11 | 12 | ### Fixed 13 | 14 | - Patch Version Parsing Code [#135](https://github.com/chelnak/gh-changelog/pull/135) ([chelnak](https://github.com/chelnak)) 15 | 16 | ## [v0.13.0](https://github.com/chelnak/gh-changelog/tree/v0.13.0) - 2023-04-15 17 | 18 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.12.1...v0.13.0) 19 | 20 | ### Added 21 | 22 | - Support secure credential storage [#133](https://github.com/chelnak/gh-changelog/pull/133) ([chelnak](https://github.com/chelnak)) 23 | 24 | ## [v0.12.1](https://github.com/chelnak/gh-changelog/tree/v0.12.1) - 2023-04-12 25 | 26 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.12.0...v0.12.1) 27 | 28 | ### Fixed 29 | 30 | - Fix config initialization [#129](https://github.com/chelnak/gh-changelog/pull/129) ([chelnak](https://github.com/chelnak)) 31 | 32 | ## [v0.12.0](https://github.com/chelnak/gh-changelog/tree/v0.12.0) - 2023-04-11 33 | 34 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.11.0...v0.12.0) 35 | 36 | ### Added 37 | 38 | - Allow local configs [#119](https://github.com/chelnak/gh-changelog/pull/119) ([chelnak](https://github.com/chelnak)) 39 | - Add a Markdown parser [#117](https://github.com/chelnak/gh-changelog/pull/117) ([chelnak](https://github.com/chelnak)) 40 | 41 | ### Fixed 42 | 43 | - Handle Pre-Releases [#126](https://github.com/chelnak/gh-changelog/pull/126) ([chelnak](https://github.com/chelnak)) 44 | 45 | ## [v0.11.0](https://github.com/chelnak/gh-changelog/tree/v0.11.0) - 2022-12-01 46 | 47 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.10.1...v0.11.0) 48 | 49 | ### Added 50 | 51 | - Convert changelog datastructure [#112](https://github.com/chelnak/gh-changelog/pull/112) ([chelnak](https://github.com/chelnak)) 52 | 53 | ### Fixed 54 | 55 | - Fix usage on repositories without tags [#114](https://github.com/chelnak/gh-changelog/pull/114) ([chelnak](https://github.com/chelnak)) 56 | 57 | ### Other 58 | 59 | - Fix markown formatting [#113](https://github.com/chelnak/gh-changelog/pull/113) ([chelnak](https://github.com/chelnak)) 60 | 61 | ## [v0.10.1](https://github.com/chelnak/gh-changelog/tree/v0.10.1) - 2022-10-20 62 | 63 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.10.0...v0.10.1) 64 | 65 | ### Fixed 66 | 67 | - Fix tags append [#109](https://github.com/chelnak/gh-changelog/pull/109) ([chelnak](https://github.com/chelnak)) 68 | 69 | ## [v0.10.0](https://github.com/chelnak/gh-changelog/tree/v0.10.0) - 2022-10-14 70 | 71 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.9.0...v0.10.0) 72 | 73 | ### Added 74 | 75 | - Rename text logger [#105](https://github.com/chelnak/gh-changelog/pull/105) ([chelnak](https://github.com/chelnak)) 76 | - Better logging control [#102](https://github.com/chelnak/gh-changelog/pull/102) ([chelnak](https://github.com/chelnak)) 77 | - Refactor builder & changelog in to pkg [#101](https://github.com/chelnak/gh-changelog/pull/101) ([chelnak](https://github.com/chelnak)) 78 | - Scoped changelogs [#100](https://github.com/chelnak/gh-changelog/pull/100) ([chelnak](https://github.com/chelnak)) 79 | 80 | ## [v0.9.0](https://github.com/chelnak/gh-changelog/tree/v0.9.0) - 2022-10-07 81 | 82 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.8.1...v0.9.0) 83 | 84 | ### Added 85 | 86 | - Help and error UX improvements [#92](https://github.com/chelnak/gh-changelog/pull/92) ([chelnak](https://github.com/chelnak)) 87 | - Refactoring and code hygiene [#88](https://github.com/chelnak/gh-changelog/pull/88) ([chelnak](https://github.com/chelnak)) 88 | - Remove internal/pkg [#86](https://github.com/chelnak/gh-changelog/pull/86) ([chelnak](https://github.com/chelnak)) 89 | - Add a new excluded label default [#82](https://github.com/chelnak/gh-changelog/pull/82) ([chelnak](https://github.com/chelnak)) 90 | 91 | ### Fixed 92 | 93 | - Properly handle a repo with no tags [#95](https://github.com/chelnak/gh-changelog/pull/95) ([chelnak](https://github.com/chelnak)) 94 | 95 | ## [v0.8.1](https://github.com/chelnak/gh-changelog/tree/v0.8.1) - 2022-06-07 96 | 97 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.8.0...v0.8.1) 98 | 99 | ### Fixed 100 | 101 | - Fix lexer in PrintYAML method [#80](https://github.com/chelnak/gh-changelog/pull/80) ([chelnak](https://github.com/chelnak)) 102 | 103 | ## [v0.8.0](https://github.com/chelnak/gh-changelog/tree/v0.8.0) - 2022-05-20 104 | 105 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.7.0...v0.8.0) 106 | 107 | ### Added 108 | 109 | - Colorize config output [#77](https://github.com/chelnak/gh-changelog/pull/77) ([chelnak](https://github.com/chelnak)) 110 | - Simplify config printing [#70](https://github.com/chelnak/gh-changelog/pull/70) ([chelnak](https://github.com/chelnak)) 111 | - Add a command to view current config [#63](https://github.com/chelnak/gh-changelog/pull/63) ([chelnak](https://github.com/chelnak)) 112 | - Enable configuration from environment [#61](https://github.com/chelnak/gh-changelog/pull/61) ([chelnak](https://github.com/chelnak)) 113 | 114 | ### Fixed 115 | 116 | - Validate next version [#76](https://github.com/chelnak/gh-changelog/pull/76) ([chelnak](https://github.com/chelnak)) 117 | - Handle orphaned commits [#74](https://github.com/chelnak/gh-changelog/pull/74) ([chelnak](https://github.com/chelnak)) 118 | 119 | ## [v0.7.0](https://github.com/chelnak/gh-changelog/tree/v0.7.0) - 2022-05-14 120 | 121 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.6.1...v0.7.0) 122 | 123 | ### Added 124 | 125 | - Lowercase section names [#52](https://github.com/chelnak/gh-changelog/pull/52) ([chelnak](https://github.com/chelnak)) 126 | - Remove additional newlines in markdown [#51](https://github.com/chelnak/gh-changelog/pull/51) ([chelnak](https://github.com/chelnak)) 127 | - Rework configuration [#47](https://github.com/chelnak/gh-changelog/pull/47) ([chelnak](https://github.com/chelnak)) 128 | 129 | ### Fixed 130 | 131 | - Ensure that Keep a Changelog format is followed [#53](https://github.com/chelnak/gh-changelog/pull/53) ([chelnak](https://github.com/chelnak)) 132 | 133 | ## [v0.6.1](https://github.com/chelnak/gh-changelog/tree/v0.6.1) - 2022-05-08 134 | 135 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.6.0...v0.6.1) 136 | 137 | ### Fixed 138 | 139 | - Remove Println [#44](https://github.com/chelnak/gh-changelog/pull/44) ([chelnak](https://github.com/chelnak)) 140 | 141 | ## [v0.6.0](https://github.com/chelnak/gh-changelog/tree/v0.6.0) - 2022-05-08 142 | 143 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.5.0...v0.6.0) 144 | 145 | ### Added 146 | 147 | - Add update check [#41](https://github.com/chelnak/gh-changelog/pull/41) ([chelnak](https://github.com/chelnak)) 148 | - Break up changelog package [#38](https://github.com/chelnak/gh-changelog/pull/38) ([chelnak](https://github.com/chelnak)) 149 | 150 | ### Fixed 151 | 152 | - Fix error messages [#37](https://github.com/chelnak/gh-changelog/pull/37) ([chelnak](https://github.com/chelnak)) 153 | 154 | ## [v0.5.0](https://github.com/chelnak/gh-changelog/tree/v0.5.0) - 2022-05-07 155 | 156 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.4.0...v0.5.0) 157 | 158 | ### Added 159 | 160 | - Implementation of next-version and unreleased [#35](https://github.com/chelnak/gh-changelog/pull/35) ([chelnak](https://github.com/chelnak)) 161 | - Refactor & (some) tests [#34](https://github.com/chelnak/gh-changelog/pull/34) ([chelnak](https://github.com/chelnak)) 162 | 163 | ## [v0.4.0](https://github.com/chelnak/gh-changelog/tree/v0.4.0) - 2022-04-26 164 | 165 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.3.1...v0.4.0) 166 | 167 | ### Added 168 | 169 | - Migrate to GitHubs v4 API [#29](https://github.com/chelnak/gh-changelog/pull/29) ([chelnak](https://github.com/chelnak)) 170 | - Better config [#28](https://github.com/chelnak/gh-changelog/pull/28) ([chelnak](https://github.com/chelnak)) 171 | - Better config [#27](https://github.com/chelnak/gh-changelog/pull/27) ([chelnak](https://github.com/chelnak)) 172 | 173 | ### Fixed 174 | 175 | - Clarify functionality in README [#25](https://github.com/chelnak/gh-changelog/pull/25) ([chelnak](https://github.com/chelnak)) 176 | 177 | ## [v0.3.1](https://github.com/chelnak/gh-changelog/tree/v0.3.1) - 2022-04-20 178 | 179 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.3.0...v0.3.1) 180 | 181 | ### Fixed 182 | 183 | - Fixes root commit reference [#24](https://github.com/chelnak/gh-changelog/pull/24) ([chelnak](https://github.com/chelnak)) 184 | 185 | ## [v0.3.0](https://github.com/chelnak/gh-changelog/tree/v0.3.0) - 2022-04-20 186 | 187 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.2.1...v0.3.0) 188 | 189 | ### Added 190 | 191 | - Turbo boost! [#20](https://github.com/chelnak/gh-changelog/pull/20) ([chelnak](https://github.com/chelnak)) 192 | 193 | ### Fixed 194 | 195 | - Set longer line length for md render [#18](https://github.com/chelnak/gh-changelog/pull/18) ([chelnak](https://github.com/chelnak)) 196 | 197 | ## [v0.2.1](https://github.com/chelnak/gh-changelog/tree/v0.2.1) - 2022-04-18 198 | 199 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.2.0...v0.2.1) 200 | 201 | ### Fixed 202 | 203 | - Fix full changelog link [#14](https://github.com/chelnak/gh-changelog/pull/14) ([chelnak](https://github.com/chelnak)) 204 | 205 | ## [v0.2.0](https://github.com/chelnak/gh-changelog/tree/v0.2.0) - 2022-04-15 206 | 207 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.1.0...v0.2.0) 208 | 209 | ### Added 210 | 211 | - Add show command [#12](https://github.com/chelnak/gh-changelog/pull/12) ([chelnak](https://github.com/chelnak)) 212 | - Implement better errors [#9](https://github.com/chelnak/gh-changelog/pull/9) ([chelnak](https://github.com/chelnak)) 213 | 214 | ## [v0.1.0](https://github.com/chelnak/gh-changelog/tree/v0.1.0) - 2022-04-15 215 | 216 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/42d4c93b23eaf307c5f9712f4c62014fe38332bd...v0.1.0) 217 | -------------------------------------------------------------------------------- /internal/get/get.go: -------------------------------------------------------------------------------- 1 | // Package get retrieves a local changelog, parses it and returns the result. 2 | package get 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/chelnak/gh-changelog/pkg/changelog" 8 | "github.com/chelnak/gh-changelog/pkg/entry" 9 | "github.com/chelnak/gh-changelog/pkg/parser" 10 | ) 11 | 12 | func getTag(parsedChangelog changelog.Changelog, tag string) *entry.Entry { 13 | currentEntry := parsedChangelog.Head() 14 | for currentEntry != nil { 15 | if currentEntry.Tag == tag { 16 | return currentEntry 17 | } 18 | currentEntry = currentEntry.Next 19 | } 20 | return nil 21 | } 22 | 23 | func parseChangelog(fileName string) (changelog.Changelog, error) { 24 | parser := parser.NewParser(fileName, "", "") 25 | return parser.Parse() 26 | } 27 | 28 | func changelogWithSingleEntry(entry entry.Entry, repoName, repoOwner string) changelog.Changelog { 29 | // Isolate the entry 30 | entry.Next = nil 31 | if entry.Previous != nil { 32 | entry.PrevTag = entry.Previous.Tag 33 | entry.Previous = nil 34 | } 35 | 36 | cl := changelog.NewChangelog(repoOwner, repoName) 37 | cl.Insert(entry) 38 | return cl 39 | } 40 | 41 | // GetVersion retrieves a local changelog, parses it and returns a string 42 | // containing only the specified version. 43 | func GetVersion(fileName string, tag string) (changelog.Changelog, error) { 44 | parsedChangelog, err := parseChangelog(fileName) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | versionEntry := getTag(parsedChangelog, tag) 50 | if versionEntry == nil { 51 | return nil, fmt.Errorf("version %s not found", tag) 52 | } 53 | 54 | cl := changelogWithSingleEntry( 55 | *versionEntry, 56 | parsedChangelog.GetRepoName(), 57 | parsedChangelog.GetRepoOwner(), 58 | ) 59 | 60 | return cl, nil 61 | } 62 | 63 | // GetLatest retrieves a local changelog, parses it and returns a string 64 | // containing only the latest entry. 65 | func GetLatest(fileName string) (changelog.Changelog, error) { 66 | parsedChangelog, err := parseChangelog(fileName) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | latestEntry := parsedChangelog.Tail() 72 | cl := changelogWithSingleEntry( 73 | *latestEntry, 74 | parsedChangelog.GetRepoName(), 75 | parsedChangelog.GetRepoOwner(), 76 | ) 77 | 78 | return cl, nil 79 | } 80 | 81 | // GetAll retrieves a local changelog, parses it and returns a string 82 | // containing all entries 83 | func GetAll(fileName string) (changelog.Changelog, error) { 84 | parsedChangelog, err := parseChangelog(fileName) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | return parsedChangelog, nil 90 | } 91 | -------------------------------------------------------------------------------- /internal/get/get_test.go: -------------------------------------------------------------------------------- 1 | package get_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/chelnak/gh-changelog/internal/get" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var fileName string = "CHANGELOG.md" 11 | var singleEntryFileName string = "single_CHANGELOG.md" 12 | 13 | func TestGetAll(t *testing.T) { 14 | cl, err := get.GetAll(fileName) 15 | 16 | // Should not error 17 | assert.Nil(t, err) 18 | 19 | // Should have at least 1 entry 20 | count := len(cl.GetEntries()) 21 | assert.Greater(t, count, 0) 22 | } 23 | 24 | func TestGetLatest(t *testing.T) { 25 | cl, err := get.GetLatest(fileName) 26 | 27 | // Should not error 28 | assert.Nil(t, err) 29 | 30 | // Should have 1 entry 31 | count := len(cl.GetEntries()) 32 | assert.Equal(t, 1, count) 33 | assert.Equal(t, "v0.13.0", cl.GetEntries()[0].PrevTag) 34 | } 35 | 36 | func TestGetLatestWithNoPrevious(t *testing.T) { 37 | cl, err := get.GetLatest(singleEntryFileName) 38 | 39 | // Should not error 40 | assert.Nil(t, err) 41 | 42 | // Should have 1 entry 43 | count := len(cl.GetEntries()) 44 | assert.Equal(t, 1, count) 45 | assert.Equal(t, "", cl.GetEntries()[0].PrevTag) 46 | } 47 | 48 | func TestGetVersionWithAValidVersion(t *testing.T) { 49 | // Should not error when version is found 50 | cl, err := get.GetVersion(fileName, "v0.9.0") 51 | assert.Nil(t, err) 52 | 53 | // Should have 1 entry 54 | count := len(cl.GetEntries()) 55 | assert.Equal(t, 1, count) 56 | assert.Equal(t, "v0.8.1", cl.GetEntries()[0].PrevTag) 57 | } 58 | 59 | func TestGetVersionWithAnInvalidVersion(t *testing.T) { 60 | // Should error when version is not found 61 | _, err := get.GetVersion(fileName, "v0.0.0") 62 | assert.NotNil(t, err) 63 | } 64 | -------------------------------------------------------------------------------- /internal/get/single_CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). 7 | 8 | ## [v0.1.0](https://github.com/chelnak/gh-changelog/tree/v0.1.0) - 2022-04-15 9 | 10 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/42d4c93b23eaf307c5f9712f4c62014fe38332bd...v0.1.0) 11 | -------------------------------------------------------------------------------- /internal/gitclient/client.go: -------------------------------------------------------------------------------- 1 | // Package gitclient is responsible for providing an interface 2 | // to the local git binary. It provides predefined calls that can 3 | // be easily consumed by other packages. 4 | package gitclient 5 | 6 | import ( 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type GitClient interface { 14 | GetFirstCommit() (string, error) 15 | GetLastCommit() (string, error) 16 | GetDateOfHash(hash string) (time.Time, error) 17 | } 18 | 19 | type execContext = func(name string, arg ...string) *exec.Cmd 20 | 21 | type execOptions struct { 22 | args []string 23 | } 24 | 25 | type git struct { 26 | execContext execContext 27 | } 28 | 29 | func (g git) exec(opts execOptions) (string, error) { 30 | // TODO: Consider not using a private exec function and hardcode 31 | // each call to git in the respective command. 32 | // For now, the lint check is disabled. 33 | // output, err := exec.Command("git", opts.args...).Output() // #nosec G204 34 | output, err := g.execContext("git", opts.args...).Output() // #nosec G204 35 | if err != nil { 36 | return "", fmt.Errorf("git command failed: %s\n%s", strings.Join(opts.args, " "), err) 37 | } 38 | 39 | return strings.Trim(string(output), "\n"), nil 40 | } 41 | 42 | func (g git) GetFirstCommit() (string, error) { 43 | response, err := g.exec(execOptions{ 44 | args: []string{"rev-list", "--max-parents=0", "HEAD", "--reverse"}, 45 | }) 46 | 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | hashes := strings.Split(response, "\n") 52 | 53 | // if len(hashes) > 1 { 54 | // //If we arrive here it means that rev-list has returned more than one commit. 55 | // //This can happen when there are orphaned commits in the repository. 56 | // //We split the response by newline and return the the item at position 0. 57 | // //TODO: Logging should be added here to explain the situation. 58 | // } 59 | 60 | return hashes[0], nil 61 | } 62 | 63 | func (g git) GetLastCommit() (string, error) { 64 | return g.exec(execOptions{ 65 | args: []string{"log", "-1", "--pretty=format:%H"}, 66 | }) 67 | } 68 | 69 | func (g git) GetDateOfHash(hash string) (time.Time, error) { 70 | date, err := g.exec(execOptions{ 71 | args: []string{"log", "-1", "--format=%cI", hash, "--date=local"}, 72 | }) 73 | 74 | if err != nil { 75 | return time.Time{}, err 76 | } 77 | 78 | return time.ParseInLocation(time.RFC3339, date, time.Local) 79 | } 80 | 81 | func NewGitClient(cmdContext execContext) GitClient { 82 | return git{ 83 | execContext: cmdContext, 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/gitclient/client_test.go: -------------------------------------------------------------------------------- 1 | package gitclient_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "testing" 8 | "time" 9 | 10 | "github.com/chelnak/gh-changelog/internal/gitclient" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var testStdoutValue = "test" 15 | 16 | func fakeExecSuccess(command string, args ...string) *exec.Cmd { 17 | cs := []string{"-test.run=TestShellProcessSuccess", "--", command} 18 | cs = append(cs, args...) 19 | expectedOutput := os.Getenv("GO_TEST_PROCESS_EXPECTED_OUTPUT") 20 | cmd := exec.Command(os.Args[0], cs...) // #nosec 21 | cmd.Env = []string{"GO_TEST_PROCESS=1", fmt.Sprintf("GO_TEST_PROCESS_EXPECTED_OUTPUT=%s", expectedOutput)} 22 | return cmd 23 | } 24 | 25 | func fakeExecFailure(command string, args ...string) *exec.Cmd { 26 | cs := []string{"-test.run=TestShellProcessFailure", "--", command} 27 | cs = append(cs, args...) 28 | cmd := exec.Command(os.Args[0], cs...) // #nosec 29 | cmd.Env = []string{"GO_TEST_PROCESS=1"} 30 | return cmd 31 | } 32 | 33 | func safeSetMockOutput(output string) func() { 34 | _ = os.Setenv("GO_TEST_PROCESS_EXPECTED_OUTPUT", output) 35 | return func() { 36 | _ = os.Unsetenv("GO_TEST_PROCESS_EXPECTED_OUTPUT") 37 | } 38 | } 39 | 40 | func TestShellProcessSuccess(t *testing.T) { 41 | if os.Getenv("GO_TEST_PROCESS") != "1" { 42 | return 43 | } 44 | 45 | output := testStdoutValue 46 | if os.Getenv("GO_TEST_PROCESS_EXPECTED_OUTPUT") != "" { 47 | output = os.Getenv("GO_TEST_PROCESS_EXPECTED_OUTPUT") 48 | } 49 | 50 | fmt.Fprint(os.Stdout, output) 51 | os.Exit(0) 52 | } 53 | 54 | func TestShellProcessFailure(t *testing.T) { 55 | if os.Getenv("GO_TEST_PROCESS") != "1" { 56 | return 57 | } 58 | fmt.Fprint(os.Stderr, "error") 59 | os.Exit(1) 60 | } 61 | 62 | func TestGetFirstCommitSuccess(t *testing.T) { 63 | gitClient := gitclient.NewGitClient(fakeExecSuccess) 64 | commit, err := gitClient.GetFirstCommit() 65 | 66 | assert.NoError(t, err) 67 | assert.Equal(t, "test", commit) 68 | } 69 | 70 | func TestGetFirstCommitFailure(t *testing.T) { 71 | gitClient := gitclient.NewGitClient(fakeExecFailure) 72 | _, err := gitClient.GetFirstCommit() 73 | 74 | assert.Error(t, err) 75 | } 76 | 77 | func TestGetFirstCommitWithOrphansSuccess(t *testing.T) { 78 | defer safeSetMockOutput("test-hash-0\ntest-hash-1")() 79 | 80 | gitClient := gitclient.NewGitClient(fakeExecSuccess) 81 | commit, err := gitClient.GetFirstCommit() 82 | 83 | assert.NoError(t, err) 84 | assert.Equal(t, "test-hash-0", commit) 85 | } 86 | 87 | func TestGetLastCommitSuccess(t *testing.T) { 88 | gitClient := gitclient.NewGitClient(fakeExecSuccess) 89 | commit, err := gitClient.GetLastCommit() 90 | 91 | assert.NoError(t, err) 92 | assert.Equal(t, "test", commit) 93 | } 94 | 95 | func TestGetLastCommitFailure(t *testing.T) { 96 | gitClient := gitclient.NewGitClient(fakeExecFailure) 97 | _, err := gitClient.GetLastCommit() 98 | 99 | assert.Error(t, err) 100 | } 101 | 102 | func TestGetDateOfHashSuccess(t *testing.T) { 103 | mockDate := "2022-04-18T19:31:31+00:00" 104 | defer safeSetMockOutput(mockDate)() 105 | 106 | gitClient := gitclient.NewGitClient(fakeExecSuccess) 107 | date, err := gitClient.GetDateOfHash("test-hash") 108 | expectedDate, _ := time.ParseInLocation(time.RFC3339, mockDate, time.Local) 109 | 110 | assert.NoError(t, err) 111 | assert.Equal(t, expectedDate, date) 112 | } 113 | -------------------------------------------------------------------------------- /internal/githubclient/client.go: -------------------------------------------------------------------------------- 1 | // Package githubclient is a wrapper around the githubv4 client. 2 | // It's purpose is to provide abstraction for some graphql queries 3 | // that retrieve data for the changelog. 4 | package githubclient 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/chelnak/gh-changelog/internal/utils" 12 | "github.com/cli/go-gh/v2/pkg/api" 13 | "github.com/shurcooL/githubv4" 14 | ) 15 | 16 | type repoContext struct { 17 | owner string 18 | name string 19 | } 20 | 21 | type GitHubClient interface { 22 | GetTags() ([]Tag, error) 23 | GetPullRequestsBetweenDates(from, to time.Time) ([]PullRequest, error) 24 | GetRepoName() string 25 | GetRepoOwner() string 26 | } 27 | 28 | type githubClient struct { 29 | base *githubv4.Client 30 | repoContext repoContext 31 | httpContext context.Context 32 | } 33 | 34 | func (client *githubClient) GetRepoName() string { 35 | return client.repoContext.name 36 | } 37 | 38 | func (client *githubClient) GetRepoOwner() string { 39 | return client.repoContext.owner 40 | } 41 | 42 | func NewGitHubClient() (GitHubClient, error) { 43 | httpClient, err := api.DefaultHTTPClient() 44 | if err != nil { 45 | return nil, fmt.Errorf("could not create initial client: %s", err) 46 | } 47 | 48 | g := githubv4.NewClient(httpClient) 49 | 50 | currentRepository, err := utils.GetRepoContext() 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | client := &githubClient{ 56 | base: g, 57 | repoContext: repoContext{ 58 | owner: currentRepository.Owner, 59 | name: currentRepository.Name, 60 | }, 61 | httpContext: context.Background(), 62 | } 63 | 64 | return client, nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/githubclient/client_test.go: -------------------------------------------------------------------------------- 1 | package githubclient_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/chelnak/gh-changelog/internal/githubclient" 7 | "github.com/chelnak/gh-changelog/mocks" 8 | "github.com/jarcoal/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_ItReturnsTheCorrectRepoName(t *testing.T) { 13 | mockClient := &mocks.GitHubClient{} 14 | mockClient.On("GetRepoName").Return("TestRepo") 15 | 16 | repoName := mockClient.GetRepoName() 17 | mockClient.AssertExpectations(t) 18 | 19 | assert.Equal(t, "TestRepo", repoName) 20 | } 21 | 22 | func Test_ItReturnsTheCorrectRepoOwner(t *testing.T) { 23 | mockClient := &mocks.GitHubClient{} 24 | mockClient.On("GetRepoOwner").Return("TestOwner") 25 | 26 | repoName := mockClient.GetRepoOwner() 27 | mockClient.AssertExpectations(t) 28 | 29 | assert.Equal(t, "TestOwner", repoName) 30 | } 31 | 32 | func NewJSONResponder(status int, body string) httpmock.Responder { 33 | resp := httpmock.NewStringResponse(status, body) 34 | resp.Header.Set("Content-Type", "application/json") 35 | return httpmock.ResponderFromResponse(resp) 36 | } 37 | 38 | func Test_GetTagsReturnsASliceOfTags(t *testing.T) { 39 | t.Skip() //Skip this test for now 40 | 41 | httpmock.Activate() 42 | defer httpmock.DeactivateAndReset() 43 | httpmock.RegisterResponder("POST", "https://api.github.com/graphql", 44 | NewJSONResponder(500, httpmock.File("data/get_tags_response.json").String()), 45 | ) 46 | 47 | t.Setenv("GITHUB_TOKEN", "xxxxxxxx") 48 | t.Setenv("GH_TOKEN", "test-token") 49 | t.Setenv("GH_REPO", "test/repo") 50 | t.Setenv("GH_CONFIG_DIR", t.TempDir()) 51 | 52 | client, err := githubclient.NewGitHubClient() 53 | 54 | assert.NoError(t, err) 55 | 56 | tags, err := client.GetTags() 57 | 58 | assert.NoError(t, err) 59 | 60 | assert.Equal(t, 2, len(tags)) 61 | assert.Equal(t, "v1.0.0", tags[0].Name) 62 | } 63 | -------------------------------------------------------------------------------- /internal/githubclient/data/get_tags_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": { 3 | "refs": { 4 | "nodes": [ 5 | { 6 | "name": "v1.0.0", 7 | "target": { 8 | "__typename": "TAG", 9 | "tag": { 10 | "oid": "1a2b3c4d5e6f7g8h9i0j", 11 | "tagger": { 12 | "date": "2018-01-01T00:00:00Z" 13 | } 14 | } 15 | } 16 | }, 17 | { 18 | "name": "v1.1.0", 19 | "target": { 20 | "__typename": "COMMIT", 21 | "tag": { 22 | "oid": "1a2b3c4d5e6f7g8h9i0j", 23 | "Committer": { 24 | "date": "2018-01-01T00:00:00Z" 25 | } 26 | } 27 | } 28 | } 29 | ], 30 | "pageInfo": { 31 | "hasNextPage": false, 32 | "endCursor": "Y3Vyc29yOnYyOpH5jw==" 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /internal/githubclient/pull_requests.go: -------------------------------------------------------------------------------- 1 | package githubclient 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/shurcooL/githubv4" 8 | ) 9 | 10 | type PullRequestLabel struct { 11 | Name string 12 | } 13 | 14 | type PullRequestEdge struct { 15 | Node struct { 16 | PullRequest struct { 17 | Number int 18 | Title string 19 | Author struct { 20 | Login string 21 | } 22 | Labels struct { 23 | Nodes []PullRequestLabel 24 | } `graphql:"labels(first: 100)"` 25 | } `graphql:"... on PullRequest"` 26 | } 27 | } 28 | 29 | type PullRequestSearchQuery struct { 30 | Search struct { 31 | Edges []PullRequestEdge 32 | PageInfo struct { 33 | EndCursor githubv4.String 34 | HasNextPage bool 35 | } 36 | } `graphql:"search(query: $query, type: ISSUE, first: 100, after: $cursor)"` 37 | } 38 | 39 | type PullRequest struct { 40 | Number int 41 | Title string 42 | User string 43 | Labels []PullRequestLabel 44 | } 45 | 46 | func (client *githubClient) GetPullRequestsBetweenDates(fromDate, toDate time.Time) ([]PullRequest, error) { 47 | variables := map[string]interface{}{ 48 | "query": githubv4.String( 49 | fmt.Sprintf( 50 | `repo:%s/%s is:pr is:merged merged:%s..%s`, 51 | client.repoContext.owner, 52 | client.repoContext.name, 53 | fromDate.Local().Format(time.RFC3339), 54 | toDate.Local().Format(time.RFC3339), 55 | ), 56 | ), 57 | "cursor": (*githubv4.String)(nil), 58 | } 59 | 60 | var pullRequestSearchQuery PullRequestSearchQuery 61 | var pullRequests []PullRequest 62 | var edges []PullRequestEdge 63 | 64 | for { 65 | err := client.base.Query(client.httpContext, &pullRequestSearchQuery, variables) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | edges = append(edges, pullRequestSearchQuery.Search.Edges...) 71 | 72 | if !pullRequestSearchQuery.Search.PageInfo.HasNextPage { 73 | break 74 | } 75 | variables["cursor"] = pullRequestSearchQuery.Search.PageInfo.EndCursor 76 | } 77 | 78 | for _, edge := range edges { 79 | pullRequests = append(pullRequests, PullRequest{ 80 | Number: edge.Node.PullRequest.Number, 81 | Title: edge.Node.PullRequest.Title, 82 | User: edge.Node.PullRequest.Author.Login, 83 | Labels: edge.Node.PullRequest.Labels.Nodes, 84 | }) 85 | } 86 | 87 | return pullRequests, nil 88 | } 89 | -------------------------------------------------------------------------------- /internal/githubclient/tags.go: -------------------------------------------------------------------------------- 1 | package githubclient 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "time" 7 | 8 | "github.com/shurcooL/githubv4" 9 | ) 10 | 11 | type RefNode struct { 12 | Name string 13 | Target struct { 14 | TypeName string `graphql:"__typename"` 15 | Tag struct { 16 | Oid string 17 | Tagger struct { 18 | Date time.Time 19 | } 20 | } `graphql:"... on Tag"` 21 | Commit struct { 22 | Oid string 23 | Committer struct { 24 | Date time.Time 25 | } 26 | } `graphql:"... on Commit"` 27 | } 28 | } 29 | 30 | type TagQuery struct { 31 | Repository struct { 32 | Refs struct { 33 | Nodes []RefNode 34 | PageInfo struct { 35 | EndCursor githubv4.String 36 | HasNextPage bool 37 | } 38 | } `graphql:"refs(refPrefix: \"refs/tags/\", last: 100, after: $cursor)"` 39 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 40 | } 41 | 42 | type Tag struct { 43 | Name string 44 | Sha string 45 | Date time.Time 46 | } 47 | 48 | func (client *githubClient) GetTags() ([]Tag, error) { 49 | variables := map[string]interface{}{ 50 | "repositoryOwner": githubv4.String(client.repoContext.owner), 51 | "repositoryName": githubv4.String(client.repoContext.name), 52 | "cursor": (*githubv4.String)(nil), 53 | } 54 | 55 | var tags []Tag 56 | var tagQuery TagQuery 57 | var nodes []RefNode 58 | 59 | for { 60 | err := client.base.Query(client.httpContext, &tagQuery, variables) 61 | if err != nil { 62 | return nil, fmt.Errorf("error getting tags: %w", err) 63 | } 64 | 65 | nodes = append(nodes, tagQuery.Repository.Refs.Nodes...) 66 | 67 | if !tagQuery.Repository.Refs.PageInfo.HasNextPage { 68 | break 69 | } 70 | 71 | variables["cursor"] = tagQuery.Repository.Refs.PageInfo.EndCursor 72 | } 73 | 74 | for _, node := range nodes { 75 | switch node.Target.TypeName { 76 | case "Tag": 77 | tags = append(tags, Tag{ 78 | Name: node.Name, 79 | Sha: node.Target.Tag.Oid, 80 | Date: node.Target.Tag.Tagger.Date, 81 | }) 82 | case "Commit": 83 | tags = append(tags, Tag{ 84 | Name: node.Name, 85 | Sha: node.Target.Commit.Oid, 86 | Date: node.Target.Commit.Committer.Date, 87 | }) 88 | } 89 | } 90 | 91 | sort.Slice(tags, func(i, j int) bool { 92 | return tags[i].Date.After(tags[j].Date) 93 | }) 94 | 95 | return tags, nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/logging/console.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | type consoleLogger struct { 11 | loggerType LoggerType 12 | } 13 | 14 | func newConsoleLogger() Logger { 15 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 16 | return &consoleLogger{ 17 | loggerType: CONSOLE, 18 | } 19 | } 20 | 21 | func (c *consoleLogger) Infof(format string, args ...interface{}) { 22 | log.Info().Msgf(format, args...) 23 | } 24 | 25 | func (c *consoleLogger) Errorf(format string, args ...interface{}) { 26 | log.Error().Msgf(format, args...) 27 | } 28 | 29 | func (c *consoleLogger) Complete() {} 30 | 31 | func (c *consoleLogger) GetType() LoggerType { 32 | return c.loggerType 33 | } 34 | -------------------------------------------------------------------------------- /internal/logging/logger.go: -------------------------------------------------------------------------------- 1 | // Package logging provides a simple logging interface for the 2 | // application. 3 | package logging 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/chelnak/gh-changelog/internal/configuration" 9 | "github.com/chelnak/gh-changelog/internal/environment" 10 | ) 11 | 12 | // LoggerType is an enum for the different types of logger 13 | type LoggerType int64 14 | 15 | const ( 16 | CONSOLE LoggerType = iota 17 | SPINNER 18 | ) 19 | 20 | // Logger is the interface for logging in the application. 21 | type Logger interface { 22 | Infof(format string, args ...interface{}) 23 | Errorf(format string, args ...interface{}) 24 | Complete() 25 | GetType() LoggerType 26 | } 27 | 28 | // NewLogger returns a new logger based on the type passed in. 29 | func NewLogger(loggerType LoggerType) Logger { 30 | switch loggerType { 31 | case CONSOLE: 32 | return newConsoleLogger() 33 | case SPINNER: 34 | return newSpinnerLogger() 35 | default: 36 | return newSpinnerLogger() 37 | } 38 | } 39 | 40 | // GetLoggerType returns the logger type from the string value 41 | // passed in. This is a convenience function for the CLI. 42 | func GetLoggerType(name string) (LoggerType, error) { 43 | if name == "" { 44 | name = configuration.Config.Logger 45 | 46 | // If we're running in a CI environment then we don't want to 47 | // use the spinner. 48 | if environment.IsCI() { 49 | name = "console" 50 | } 51 | } 52 | 53 | var loggerType LoggerType 54 | switch name { 55 | case "console": 56 | loggerType = CONSOLE 57 | case "spinner": 58 | loggerType = SPINNER 59 | default: 60 | return loggerType, fmt.Errorf("'%s' is not a valid logger. Valid values are 'spinner' and 'console'", name) 61 | } 62 | 63 | return loggerType, nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/logging/logger_test.go: -------------------------------------------------------------------------------- 1 | package logging_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/chelnak/gh-changelog/internal/logging" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewLoggerReturnsTheCorrectType(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | want logging.LoggerType 14 | hasError bool 15 | }{ 16 | { 17 | name: "returns a console logger", 18 | want: logging.CONSOLE, 19 | hasError: false, 20 | }, 21 | { 22 | name: "returns a spinner logger", 23 | want: logging.SPINNER, 24 | hasError: false, 25 | }, 26 | } 27 | 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | got := logging.NewLogger(tt.want) 31 | assert.Equal(t, tt.want, got.GetType()) 32 | }) 33 | } 34 | } 35 | 36 | func TestGetLoggerTypeReturnsTheCorrectType(t *testing.T) { 37 | tests := []struct { 38 | name string 39 | input string 40 | want logging.LoggerType 41 | hasError bool 42 | }{ 43 | { 44 | name: "returns a console logger", 45 | input: "console", 46 | want: logging.CONSOLE, 47 | hasError: false, 48 | }, 49 | { 50 | name: "returns a spinner logger", 51 | input: "spinner", 52 | want: logging.SPINNER, 53 | hasError: false, 54 | }, 55 | { 56 | name: "returns an error for an invalid logger", 57 | input: "invalid", 58 | want: logging.SPINNER, 59 | hasError: true, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | got, err := logging.GetLoggerType(tt.input) 66 | if tt.hasError { 67 | assert.Error(t, err) 68 | assert.Equal(t, "'invalid' is not a valid logger. Valid values are 'spinner' and 'console'", err.Error()) 69 | } else { 70 | assert.NoError(t, err) 71 | assert.Equal(t, tt.want, got) 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/logging/spinner.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chelnak/ysmrr" 7 | ) 8 | 9 | type spinnerLogger struct { 10 | manager ysmrr.SpinnerManager 11 | spinner *ysmrr.Spinner 12 | loggerType LoggerType 13 | } 14 | 15 | func newSpinnerLogger() Logger { 16 | manager := ysmrr.NewSpinnerManager() 17 | spinner := manager.AddSpinner("Loading..") 18 | manager.Start() 19 | return &spinnerLogger{ 20 | manager: manager, 21 | spinner: spinner, 22 | loggerType: SPINNER, 23 | } 24 | } 25 | 26 | func (s *spinnerLogger) Infof(format string, args ...interface{}) { 27 | message := fmt.Sprintf(format, args...) 28 | s.spinner.UpdateMessage(message) 29 | } 30 | 31 | func (s *spinnerLogger) Errorf(format string, args ...interface{}) { 32 | message := fmt.Sprintf(format, args...) 33 | s.spinner.UpdateMessage(message) 34 | s.spinner.Error() 35 | } 36 | 37 | func (s *spinnerLogger) Complete() { 38 | s.spinner.Complete() 39 | s.manager.Stop() 40 | } 41 | 42 | func (s *spinnerLogger) GetType() LoggerType { 43 | return s.loggerType 44 | } 45 | -------------------------------------------------------------------------------- /internal/show/show.go: -------------------------------------------------------------------------------- 1 | // Package show is responsible for rendering the contents of a 2 | // given CHANGELOG.md file and displaying it in the terminal. 3 | package show 4 | 5 | import ( 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/charmbracelet/glamour" 11 | ) 12 | 13 | func Render(path string) error { 14 | r, _ := glamour.NewTermRenderer( 15 | glamour.WithAutoStyle(), 16 | glamour.WithEmoji(), 17 | glamour.WithWordWrap(140), // TODO: make this configurable 18 | ) 19 | 20 | data, err := os.ReadFile(filepath.Clean(path)) 21 | if err != nil { 22 | return errors.New("changelog not found. Check your configuration or run gh changelog new") 23 | } 24 | 25 | content, err := r.Render(string(data)) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | return start(content) 31 | } 32 | -------------------------------------------------------------------------------- /internal/show/show_test.go: -------------------------------------------------------------------------------- 1 | package show_test 2 | -------------------------------------------------------------------------------- /internal/show/viewport.go: -------------------------------------------------------------------------------- 1 | // Package show is responsible for rendering the contents of a 2 | // given CHANGELOG.md file and displaying it in the terminal. 3 | // The contents of this viewport file has been taken from the following 4 | // example: 5 | // https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go 6 | package show 7 | 8 | import ( 9 | "fmt" 10 | "strings" 11 | 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | ) 16 | 17 | const useHighPerformanceRenderer = false 18 | 19 | var ( 20 | titleStyle = func() lipgloss.Style { 21 | b := lipgloss.RoundedBorder() 22 | b.Right = "├" 23 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 24 | }() 25 | 26 | infoStyle = func() lipgloss.Style { 27 | b := lipgloss.RoundedBorder() 28 | b.Left = "┤" 29 | return titleStyle.Copy().BorderStyle(b) 30 | }() 31 | ) 32 | 33 | type model struct { 34 | content string 35 | ready bool 36 | viewport viewport.Model 37 | } 38 | 39 | func (m model) Init() tea.Cmd { 40 | return nil 41 | } 42 | 43 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 44 | var ( 45 | cmd tea.Cmd 46 | cmds []tea.Cmd 47 | ) 48 | 49 | switch msg := msg.(type) { 50 | case tea.KeyMsg: 51 | if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { 52 | return m, tea.Quit 53 | } 54 | 55 | case tea.WindowSizeMsg: 56 | headerHeight := lipgloss.Height(m.headerView()) 57 | footerHeight := lipgloss.Height(m.footerView()) 58 | verticalMarginHeight := headerHeight + footerHeight 59 | 60 | if !m.ready { 61 | m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 62 | m.viewport.YPosition = headerHeight 63 | m.viewport.HighPerformanceRendering = useHighPerformanceRenderer 64 | m.viewport.SetContent(m.content) 65 | m.ready = true 66 | 67 | m.viewport.YPosition = headerHeight + 1 68 | } else { 69 | m.viewport.Width = msg.Width 70 | m.viewport.Height = msg.Height - verticalMarginHeight 71 | } 72 | 73 | if useHighPerformanceRenderer { 74 | cmds = append(cmds, viewport.Sync(m.viewport)) 75 | } 76 | } 77 | 78 | m.viewport, cmd = m.viewport.Update(msg) 79 | cmds = append(cmds, cmd) 80 | 81 | return m, tea.Batch(cmds...) 82 | } 83 | 84 | func (m model) View() string { 85 | if !m.ready { 86 | return "\n Initializing..." 87 | } 88 | return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) 89 | } 90 | 91 | func (m model) headerView() string { 92 | title := titleStyle.Render("Changelog") 93 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) 94 | return lipgloss.JoinHorizontal(lipgloss.Center, title, line) 95 | } 96 | 97 | func (m model) footerView() string { 98 | info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 99 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) 100 | return lipgloss.JoinHorizontal(lipgloss.Center, line, info) 101 | } 102 | 103 | func max(a, b int) int { 104 | if a > b { 105 | return a 106 | } 107 | return b 108 | } 109 | 110 | func start(content string) error { 111 | p := tea.NewProgram( 112 | model{content: content}, 113 | tea.WithAltScreen(), 114 | tea.WithMouseCellMotion(), 115 | ) 116 | 117 | if _, err := p.Run(); err != nil { 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | // Package utils contains a number generic of methods that are used 2 | // throughout the application. 3 | package utils 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/chelnak/gh-changelog/internal/version" 13 | "github.com/cli/go-gh/v2/pkg/repository" 14 | "github.com/fatih/color" 15 | ) 16 | 17 | func SliceContainsString(s []string, str string) bool { 18 | for _, v := range s { 19 | if v == str { 20 | return true 21 | } 22 | } 23 | 24 | return false 25 | } 26 | 27 | func IsValidSemanticVersion(v string) bool { 28 | _, err := version.NormalizeVersion(v) 29 | return err == nil 30 | } 31 | 32 | type Release struct { 33 | Version string `json:"tag_name"` 34 | } 35 | 36 | func CheckForUpdate(currentVersion string) bool { 37 | release, err := requestLatestRelease() 38 | if err != nil { 39 | return false 40 | } 41 | 42 | currentVersion = parseLocalVersion(currentVersion) 43 | 44 | if NextVersionIsGreaterThanCurrent(release.Version, currentVersion) { 45 | color.Yellow("\nVersion %s is available ✨\n\n", release.Version) 46 | fmt.Println("Run", color.GreenString(`gh extension upgrade chelnak/gh-changelog`), "to upgrade.") 47 | 48 | fmt.Println("\nAlternatively, you can disable this check by setting", color.GreenString("check_for_updates"), "to", color.RedString("false"), "via the configuration.") 49 | fmt.Println() 50 | return true 51 | } 52 | 53 | return false 54 | } 55 | 56 | func requestLatestRelease() (Release, error) { 57 | response, err := http.Get("https://api.github.com/repos/chelnak/gh-changelog/releases/latest") 58 | if err != nil { 59 | return Release{}, err 60 | } 61 | 62 | body, err := io.ReadAll(response.Body) 63 | if err != nil { 64 | return Release{}, err 65 | } 66 | 67 | var release Release 68 | err = json.Unmarshal(body, &release) 69 | if err != nil { 70 | return Release{}, err 71 | } 72 | 73 | return release, nil 74 | } 75 | 76 | func NextVersionIsGreaterThanCurrent(nextVersion, currentVersion string) bool { 77 | currentSemVer, err := version.NormalizeVersion(currentVersion) 78 | if err != nil { 79 | return false 80 | } 81 | 82 | // The nextVersion has already been validated by the builder 83 | // so we can safely eat the error. 84 | nextSemVer, err := version.NormalizeVersion(nextVersion) 85 | if err != nil { 86 | return false 87 | } 88 | 89 | return nextSemVer.GreaterThan(currentSemVer) 90 | } 91 | 92 | func parseLocalVersion(version string) string { 93 | slice := strings.Split(version, " ") 94 | 95 | if len(slice) == 1 { 96 | return version 97 | } 98 | 99 | return slice[2] 100 | } 101 | 102 | // RepoContext is a struct that contains the current repository owner and name. 103 | type RepoContext struct { 104 | Owner string 105 | Name string 106 | } 107 | 108 | // GetRepoContext returns a new RepoContext struct with the current repository owner and name. 109 | func GetRepoContext() (RepoContext, error) { 110 | currentRepository, err := repository.Current() 111 | if err != nil { 112 | if strings.Contains(err.Error(), "not a git repository (or any of the parent directories)") { 113 | return RepoContext{}, fmt.Errorf("the current directory is not a git repository") 114 | } 115 | 116 | return RepoContext{}, err 117 | } 118 | 119 | return RepoContext{ 120 | Owner: currentRepository.Owner, 121 | Name: currentRepository.Name, 122 | }, nil 123 | } 124 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/chelnak/gh-changelog/internal/utils" 7 | "github.com/stretchr/testify/assert" 8 | "gopkg.in/h2non/gock.v1" 9 | ) 10 | 11 | func TestSliceContainsString(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | slice []string 15 | value string 16 | want bool 17 | }{ 18 | { 19 | name: "slice contains value", 20 | slice: []string{"a", "b", "c"}, 21 | value: "b", 22 | want: true, 23 | }, 24 | { 25 | name: "slice does not contain value", 26 | slice: []string{"a", "b", "c"}, 27 | value: "d", 28 | want: false, 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | if got := utils.SliceContainsString(tt.slice, tt.value); got != tt.want { 35 | t.Errorf("SliceContainsString() = %v, want %v", got, tt.want) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestIsValidSemanticVersion(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | value string 45 | want bool 46 | }{ 47 | { 48 | name: "valid semantic version", 49 | value: "1.0.0", 50 | want: true, 51 | }, 52 | { 53 | name: "valid semantic version with pre-release", 54 | value: "1.0.0-beta", 55 | want: true, 56 | }, 57 | { 58 | name: "invalid semantic version", 59 | value: "asdasdasd1", 60 | want: false, 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | if got := utils.IsValidSemanticVersion(tt.value); got != tt.want { 67 | t.Errorf("IsValidSemanticVersion() = %v, want %v", got, tt.want) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func TestVersionIsGreaterThan(t *testing.T) { 74 | tests := []struct { 75 | name string 76 | value string 77 | want bool 78 | }{ 79 | { 80 | name: "version is greater than", 81 | value: "2.0.0", 82 | want: true, 83 | }, 84 | { 85 | name: "version is not greater than", 86 | value: "0.1.0", 87 | want: false, 88 | }, 89 | { 90 | name: "when the version is equal", 91 | value: "1.0.0", 92 | want: false, 93 | }, 94 | { 95 | name: "when the version is greater with pre-release", 96 | value: "1.0.1-beta", 97 | want: true, 98 | }, 99 | { 100 | name: "version is not greater than with pre-release", 101 | value: "0.2.0-beta", 102 | want: false, 103 | }, 104 | { 105 | name: "when the version is equal with pre-release", 106 | value: "1.0.0-beta", 107 | want: false, 108 | }, 109 | } 110 | 111 | for _, tt := range tests { 112 | t.Run(tt.name, func(t *testing.T) { 113 | assert.Equal(t, tt.want, utils.NextVersionIsGreaterThanCurrent(tt.value, "1.0.0")) 114 | }) 115 | } 116 | } 117 | 118 | func TestVersionIsGreaterThanPreRelease(t *testing.T) { 119 | tests := []struct { 120 | name string 121 | value string 122 | want bool 123 | }{ 124 | { 125 | name: "version is greater than and not a pre-release", 126 | value: "7.0.0", 127 | want: true, 128 | }, 129 | { 130 | name: "version is greater than and is a standard release", 131 | value: "6.0.0", 132 | want: true, 133 | }, 134 | { 135 | name: "version is greater than and is a pre-release", 136 | value: "6.0.1-rc.1", 137 | want: true, 138 | }, 139 | { 140 | name: "version is not greater than and not a pre-repease", 141 | value: "0.1.0", 142 | want: false, 143 | }, 144 | { 145 | name: "version is not greater than and is a pre-repease", 146 | value: "v0.1.0-rc.1", 147 | want: false, 148 | }, 149 | } 150 | 151 | for _, tt := range tests { 152 | t.Run(tt.name, func(t *testing.T) { 153 | assert.Equal(t, tt.want, utils.NextVersionIsGreaterThanCurrent(tt.value, "6.0.0-rc.1")) 154 | }) 155 | } 156 | } 157 | 158 | func TestVersionParsesWithDifferentPreReleaseDelimeters(t *testing.T) { 159 | tests := []struct { 160 | name string 161 | value string 162 | want bool 163 | }{ 164 | { 165 | name: "version is greater than and is a pre-release, using a -", 166 | value: "6.0.1-rc.1", 167 | want: true, 168 | }, 169 | { 170 | name: "version is greater than and is a pre-release, using a .", 171 | value: "6.0.1-rc.1", 172 | want: true, 173 | }, 174 | { 175 | name: "version is not greater than and is a pre-repease, using a -", 176 | value: "v0.1.0-rc.1", 177 | want: false, 178 | }, 179 | { 180 | name: "version is not greater than and is a pre-repease, using a .", 181 | value: "v0.1.0.rc.1", 182 | want: false, 183 | }, 184 | } 185 | 186 | for _, tt := range tests { 187 | t.Run(tt.name, func(t *testing.T) { 188 | assert.Equal(t, tt.want, utils.NextVersionIsGreaterThanCurrent(tt.value, "6.0.0-rc.1")) 189 | }) 190 | } 191 | } 192 | 193 | func TestCheckForUpdates(t *testing.T) { 194 | tests := []struct { 195 | name string 196 | currentVersion string 197 | nextVersion string 198 | want bool 199 | }{ 200 | { 201 | name: "an update is available", 202 | currentVersion: "changelog version 1.0.0", 203 | nextVersion: "v1.0.1", 204 | want: true, 205 | }, 206 | { 207 | name: "no update is available", 208 | currentVersion: "changelog version 1.0.0", 209 | nextVersion: "1.0.0", 210 | want: false, 211 | }, 212 | } 213 | 214 | defer gock.Off() 215 | 216 | for _, tt := range tests { 217 | t.Run(tt.name, func(t *testing.T) { 218 | gock.New("https://api.github.com"). 219 | Get("/repos/chelnak/gh-changelog/releases/latest"). 220 | Reply(200). 221 | JSON(map[string]string{"tag_name": tt.nextVersion}) 222 | 223 | got := utils.CheckForUpdate(tt.currentVersion) 224 | assert.Equal(t, tt.want, got) 225 | }) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | // Package version contains a wrapper for parsing semantic versions with the 2 | // semver library. 3 | // The code here will be removed if/when semver can handle pre-release versions with a dot. 4 | // It's not ideal but a reasonable workaround for now. 5 | package version 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/Masterminds/semver/v3" 15 | ) 16 | 17 | // The compiled version of the regex created at init() is cached here so it 18 | // only needs to be created once. 19 | var versionRegex *regexp.Regexp 20 | 21 | // semVerRegex is the regular expression used to parse a semantic version. 22 | const semVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` + 23 | `((?:-|\.)([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + 24 | `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` 25 | 26 | // Version represents a single semantic version. 27 | type Version struct { 28 | major, minor, patch uint64 29 | pre string 30 | metadata string 31 | original string 32 | } 33 | 34 | func init() { 35 | versionRegex = regexp.MustCompile("^" + semVerRegex + "$") 36 | } 37 | 38 | const ( 39 | num string = "0123456789" 40 | allowed string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-" + num 41 | ) 42 | 43 | // NormalizeVersion is basically a copy of SemVers NewVersion. However, it's 44 | // purpose is to normalize the version string to a semver compatible one. 45 | func NormalizeVersion(v string) (*semver.Version, error) { 46 | m := versionRegex.FindStringSubmatch(v) 47 | if m == nil { 48 | return nil, semver.ErrInvalidSemVer 49 | } 50 | 51 | sv := &Version{ 52 | metadata: m[8], 53 | pre: m[5], 54 | original: v, 55 | } 56 | 57 | var err error 58 | sv.major, err = strconv.ParseUint(m[1], 10, 64) 59 | if err != nil { 60 | return nil, fmt.Errorf("error parsing version segment: %s", err) 61 | } 62 | 63 | if m[2] != "" { 64 | sv.minor, err = strconv.ParseUint(strings.TrimPrefix(m[2], "."), 10, 64) 65 | if err != nil { 66 | return nil, fmt.Errorf("error parsing version segment: %s", err) 67 | } 68 | } else { 69 | sv.minor = 0 70 | } 71 | 72 | if m[3] != "" { 73 | sv.patch, err = strconv.ParseUint(strings.TrimPrefix(m[3], "."), 10, 64) 74 | if err != nil { 75 | return nil, fmt.Errorf("error parsing version segment: %s", err) 76 | } 77 | } else { 78 | sv.patch = 0 79 | } 80 | 81 | // Perform some basic due diligence on the extra parts to ensure they are 82 | // valid. 83 | 84 | if sv.pre != "" { 85 | if err = validatePrerelease(sv.pre); err != nil { 86 | return nil, err 87 | } 88 | } 89 | 90 | if sv.metadata != "" { 91 | if err = validateMetadata(sv.metadata); err != nil { 92 | return nil, err 93 | } 94 | } 95 | 96 | // Return the semver version. 97 | return semver.NewVersion(sv.String()) 98 | } 99 | 100 | // String converts a Version object to a string. 101 | // Note, if the original version contained a leading v this version will not. 102 | // See the Original() method to retrieve the original value. Semantic Versions 103 | // don't contain a leading v per the spec. Instead it's optional on 104 | // implementation. 105 | func (v Version) String() string { 106 | var buf bytes.Buffer 107 | 108 | fmt.Fprintf(&buf, "%d.%d.%d", v.major, v.minor, v.patch) 109 | if v.pre != "" { 110 | fmt.Fprintf(&buf, "-%s", v.pre) 111 | } 112 | if v.metadata != "" { 113 | fmt.Fprintf(&buf, "+%s", v.metadata) 114 | } 115 | 116 | return buf.String() 117 | } 118 | 119 | // Like strings.ContainsAny but does an only instead of any. 120 | func containsOnly(s string, comp string) bool { 121 | return strings.IndexFunc(s, func(r rune) bool { 122 | return !strings.ContainsRune(comp, r) 123 | }) == -1 124 | } 125 | 126 | // From the spec, "Identifiers MUST comprise only 127 | // ASCII alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty. 128 | // Numeric identifiers MUST NOT include leading zeroes.". These segments can 129 | // be dot separated. 130 | func validatePrerelease(p string) error { 131 | eparts := strings.Split(p, ".") 132 | for _, p := range eparts { 133 | if containsOnly(p, num) { 134 | if len(p) > 1 && p[0] == '0' { 135 | return semver.ErrSegmentStartsZero 136 | } 137 | } else if !containsOnly(p, allowed) { 138 | return semver.ErrInvalidPrerelease 139 | } 140 | } 141 | 142 | return nil 143 | } 144 | 145 | // From the spec, "Build metadata MAY be denoted by 146 | // appending a plus sign and a series of dot separated identifiers immediately 147 | // following the patch or pre-release version. Identifiers MUST comprise only 148 | // ASCII alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty." 149 | func validateMetadata(m string) error { 150 | eparts := strings.Split(m, ".") 151 | for _, p := range eparts { 152 | if !containsOnly(p, allowed) { 153 | return semver.ErrInvalidMetadata 154 | } 155 | } 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /internal/writer/writer.go: -------------------------------------------------------------------------------- 1 | // Package writer is responsible for parsing the given changelog struct 2 | // into a go template and writing it to the given writer. 3 | package writer 4 | 5 | import ( 6 | "io" 7 | "os/exec" 8 | "text/template" 9 | 10 | "github.com/chelnak/gh-changelog/internal/gitclient" 11 | "github.com/chelnak/gh-changelog/pkg/changelog" 12 | ) 13 | 14 | const tmplStandard = ` 15 | # Changelog 16 | 17 | All notable changes to this project will be documented in this file. 18 | 19 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). 20 | 21 | {{- $unreleased := .GetUnreleased }} 22 | {{- if $unreleased }} 23 | 24 | ## Unreleased 25 | {{range $unreleased }} 26 | - {{.}} 27 | {{- end -}} 28 | {{- end }} 29 | {{range .GetEntries}} 30 | ## [{{.Tag}}](https://github.com/{{$.GetRepoOwner}}/{{$.GetRepoName}}/tree/{{.Tag}}) - {{.Date.Format "2006-01-02"}} 31 | {{ if .Previous }} 32 | [Full Changelog](https://github.com/{{$.GetRepoOwner}}/{{$.GetRepoName}}/compare/{{.Previous.Tag}}...{{.Tag}}) 33 | {{else}} 34 | [Full Changelog](https://github.com/{{$.GetRepoOwner}}/{{$.GetRepoName}}/compare/{{if .PrevTag }}{{.PrevTag}}{{else}}{{getFirstCommit}}{{end}}...{{.Tag}}) 35 | {{- end -}} 36 | 37 | {{- if .Security }} 38 | ### Security 39 | {{range .Security}} 40 | - {{.}} 41 | {{- end}} 42 | {{end}} 43 | {{- if .Changed }} 44 | ### Changed 45 | {{range .Changed}} 46 | - {{.}} 47 | {{- end}} 48 | {{end}} 49 | {{- if .Removed }} 50 | ### Removed 51 | {{range .Removed}} 52 | - {{.}} 53 | {{- end}} 54 | {{end}} 55 | {{- if .Deprecated }} 56 | ### Deprecated 57 | {{range .Deprecated}} 58 | - {{.}} 59 | {{- end}} 60 | {{end}} 61 | {{- if .Added }} 62 | ### Added 63 | {{range .Added}} 64 | - {{.}} 65 | {{- end}} 66 | {{end}} 67 | {{- if .Fixed }} 68 | ### Fixed 69 | {{range .Fixed}} 70 | - {{.}} 71 | {{- end}} 72 | {{end}} 73 | {{- if .Other }} 74 | ### Other 75 | {{range .Other}} 76 | - {{.}} 77 | {{- end}} 78 | {{end}} 79 | {{- end}} 80 | ` 81 | 82 | const tmplNotes = `{{range .GetEntries }} 83 | {{- if .Security }} 84 | ### Security 85 | {{range .Security}} 86 | - {{.}} 87 | {{- end}} 88 | {{end}} 89 | {{- if .Changed }} 90 | ### Changed 91 | {{range .Changed}} 92 | - {{.}} 93 | {{- end}} 94 | {{end}} 95 | {{- if .Removed }} 96 | ### Removed 97 | {{range .Removed}} 98 | - {{.}} 99 | {{- end}} 100 | {{end}} 101 | {{- if .Deprecated }} 102 | ### Deprecated 103 | {{range .Deprecated}} 104 | - {{.}} 105 | {{- end}} 106 | {{end}} 107 | {{- if .Added }} 108 | ### Added 109 | {{range .Added}} 110 | - {{.}} 111 | {{- end}} 112 | {{end}} 113 | {{- if .Fixed }} 114 | ### Fixed 115 | {{range .Fixed}} 116 | - {{.}} 117 | {{- end}} 118 | {{end}} 119 | {{- if .Other }} 120 | ### Other 121 | {{range .Other}} 122 | - {{.}} 123 | {{- end}} 124 | {{end}} 125 | {{- end}}` 126 | 127 | const ( 128 | TmplSrcStandard = tmplStandard 129 | TmplSrcNotes = tmplNotes 130 | ) 131 | 132 | func Write(writer io.Writer, tmplSrc string, changelog changelog.Changelog) error { 133 | tmpl, err := template.New("changelog").Funcs(template.FuncMap{ 134 | "getFirstCommit": func() string { 135 | git := gitclient.NewGitClient(exec.Command) 136 | commit, err := git.GetFirstCommit() 137 | if err != nil { 138 | return "" 139 | } 140 | return commit 141 | }, 142 | }).Parse(tmplSrc) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | return tmpl.Execute(writer, changelog) 148 | } 149 | -------------------------------------------------------------------------------- /internal/writer/writer_test.go: -------------------------------------------------------------------------------- 1 | package writer_test 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "testing" 7 | "time" 8 | 9 | "github.com/chelnak/gh-changelog/internal/writer" 10 | "github.com/chelnak/gh-changelog/pkg/changelog" 11 | "github.com/chelnak/gh-changelog/pkg/entry" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | const ( 16 | repoName = "repo-name" 17 | repoOwner = "repo-owner" 18 | ) 19 | 20 | func Test_ItWritesOutAChangelogInTheCorrectFormat(t *testing.T) { 21 | mockChangelog := changelog.NewChangelog(repoOwner, repoName) 22 | 23 | one := entry.Entry{ 24 | Tag: "v1.0.0", 25 | Date: time.Now(), 26 | Added: []string{"Added 1", "Added 2"}, 27 | Changed: []string{"Changed 1", "Changed 2"}, 28 | Deprecated: []string{"Deprecated 1", "Deprecated 2"}, 29 | Removed: []string{"Removed 1", "Removed 2"}, 30 | Fixed: []string{"Fixed 1", "Fixed 2"}, 31 | Security: []string{"Security 1", "Security 2"}, 32 | Other: []string{"Other 1", "Other 2"}, 33 | } 34 | 35 | two := one 36 | two.Tag = "v0.9.0" 37 | one.Previous = &two 38 | 39 | mockChangelog.Insert(one) 40 | mockChangelog.AddUnreleased([]string{"Unreleased 1", "Unreleased 2"}) 41 | 42 | var buf bytes.Buffer 43 | err := writer.Write(&buf, writer.TmplSrcStandard, mockChangelog) 44 | 45 | assert.NoError(t, err) 46 | 47 | assert.Regexp(t, "## Unreleased", buf.String()) 48 | assert.Regexp(t, "- Unreleased 1", buf.String()) 49 | assert.Regexp(t, "- Unreleased 2", buf.String()) 50 | 51 | assert.Regexp(t, regexp.MustCompile(`## \[v1.0.0\]\(https:\/\/github.com\/repo-owner\/repo-name\/tree\/v1.0.0\)`), buf.String()) 52 | assert.Regexp(t, regexp.MustCompile(`\[Full Changelog\]\(https:\/\/github.com\/repo-owner\/repo-name\/compare\/v0.9.0\.\.\.v1.0.0\)`), buf.String()) 53 | 54 | assert.Regexp(t, "### Added", buf.String()) 55 | assert.Regexp(t, "- Added 1", buf.String()) 56 | assert.Regexp(t, "- Added 2", buf.String()) 57 | 58 | assert.Regexp(t, "### Other", buf.String()) 59 | assert.Regexp(t, "- Other 1", buf.String()) 60 | assert.Regexp(t, "- Other 2", buf.String()) 61 | 62 | buf.Reset() 63 | err = writer.Write(&buf, writer.TmplSrcNotes, mockChangelog) 64 | 65 | assert.NoError(t, err) 66 | 67 | assert.NotRegexp(t, regexp.MustCompile(`## \[v1.0.0\]\(https:\/\/github.com\/repo-owner\/repo-name\/tree\/v1.0.0\)`), buf.String()) 68 | assert.NotRegexp(t, regexp.MustCompile(`\[Full Changelog\]\(https:\/\/github.com\/repo-owner\/repo-name\/compare\/v0.9.0\.\.\.v1.0.0\)`), buf.String()) 69 | } 70 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/chelnak/gh-changelog/cmd" 7 | ) 8 | 9 | func main() { 10 | os.Exit(cmd.Execute()) 11 | } 12 | -------------------------------------------------------------------------------- /mocks/Builder.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.40.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | changelog "github.com/chelnak/gh-changelog/pkg/changelog" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Builder is an autogenerated mock type for the Builder type 11 | type Builder struct { 12 | mock.Mock 13 | } 14 | 15 | // BuildChangelog provides a mock function with given fields: 16 | func (_m *Builder) BuildChangelog() (changelog.Changelog, error) { 17 | ret := _m.Called() 18 | 19 | if len(ret) == 0 { 20 | panic("no return value specified for BuildChangelog") 21 | } 22 | 23 | var r0 changelog.Changelog 24 | var r1 error 25 | if rf, ok := ret.Get(0).(func() (changelog.Changelog, error)); ok { 26 | return rf() 27 | } 28 | if rf, ok := ret.Get(0).(func() changelog.Changelog); ok { 29 | r0 = rf() 30 | } else { 31 | if ret.Get(0) != nil { 32 | r0 = ret.Get(0).(changelog.Changelog) 33 | } 34 | } 35 | 36 | if rf, ok := ret.Get(1).(func() error); ok { 37 | r1 = rf() 38 | } else { 39 | r1 = ret.Error(1) 40 | } 41 | 42 | return r0, r1 43 | } 44 | 45 | // NewBuilder creates a new instance of Builder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 46 | // The first argument is typically a *testing.T value. 47 | func NewBuilder(t interface { 48 | mock.TestingT 49 | Cleanup(func()) 50 | }) *Builder { 51 | mock := &Builder{} 52 | mock.Mock.Test(t) 53 | 54 | t.Cleanup(func() { mock.AssertExpectations(t) }) 55 | 56 | return mock 57 | } 58 | -------------------------------------------------------------------------------- /mocks/Changelog.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.40.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | entry "github.com/chelnak/gh-changelog/pkg/entry" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Changelog is an autogenerated mock type for the Changelog type 11 | type Changelog struct { 12 | mock.Mock 13 | } 14 | 15 | // AddUnreleased provides a mock function with given fields: _a0 16 | func (_m *Changelog) AddUnreleased(_a0 []string) { 17 | _m.Called(_a0) 18 | } 19 | 20 | // GetEntries provides a mock function with given fields: 21 | func (_m *Changelog) GetEntries() []*entry.Entry { 22 | ret := _m.Called() 23 | 24 | if len(ret) == 0 { 25 | panic("no return value specified for GetEntries") 26 | } 27 | 28 | var r0 []*entry.Entry 29 | if rf, ok := ret.Get(0).(func() []*entry.Entry); ok { 30 | r0 = rf() 31 | } else { 32 | if ret.Get(0) != nil { 33 | r0 = ret.Get(0).([]*entry.Entry) 34 | } 35 | } 36 | 37 | return r0 38 | } 39 | 40 | // GetRepoName provides a mock function with given fields: 41 | func (_m *Changelog) GetRepoName() string { 42 | ret := _m.Called() 43 | 44 | if len(ret) == 0 { 45 | panic("no return value specified for GetRepoName") 46 | } 47 | 48 | var r0 string 49 | if rf, ok := ret.Get(0).(func() string); ok { 50 | r0 = rf() 51 | } else { 52 | r0 = ret.Get(0).(string) 53 | } 54 | 55 | return r0 56 | } 57 | 58 | // GetRepoOwner provides a mock function with given fields: 59 | func (_m *Changelog) GetRepoOwner() string { 60 | ret := _m.Called() 61 | 62 | if len(ret) == 0 { 63 | panic("no return value specified for GetRepoOwner") 64 | } 65 | 66 | var r0 string 67 | if rf, ok := ret.Get(0).(func() string); ok { 68 | r0 = rf() 69 | } else { 70 | r0 = ret.Get(0).(string) 71 | } 72 | 73 | return r0 74 | } 75 | 76 | // GetUnreleased provides a mock function with given fields: 77 | func (_m *Changelog) GetUnreleased() []string { 78 | ret := _m.Called() 79 | 80 | if len(ret) == 0 { 81 | panic("no return value specified for GetUnreleased") 82 | } 83 | 84 | var r0 []string 85 | if rf, ok := ret.Get(0).(func() []string); ok { 86 | r0 = rf() 87 | } else { 88 | if ret.Get(0) != nil { 89 | r0 = ret.Get(0).([]string) 90 | } 91 | } 92 | 93 | return r0 94 | } 95 | 96 | // Head provides a mock function with given fields: 97 | func (_m *Changelog) Head() *entry.Entry { 98 | ret := _m.Called() 99 | 100 | if len(ret) == 0 { 101 | panic("no return value specified for Head") 102 | } 103 | 104 | var r0 *entry.Entry 105 | if rf, ok := ret.Get(0).(func() *entry.Entry); ok { 106 | r0 = rf() 107 | } else { 108 | if ret.Get(0) != nil { 109 | r0 = ret.Get(0).(*entry.Entry) 110 | } 111 | } 112 | 113 | return r0 114 | } 115 | 116 | // Insert provides a mock function with given fields: _a0 117 | func (_m *Changelog) Insert(_a0 entry.Entry) { 118 | _m.Called(_a0) 119 | } 120 | 121 | // Tail provides a mock function with given fields: 122 | func (_m *Changelog) Tail() *entry.Entry { 123 | ret := _m.Called() 124 | 125 | if len(ret) == 0 { 126 | panic("no return value specified for Tail") 127 | } 128 | 129 | var r0 *entry.Entry 130 | if rf, ok := ret.Get(0).(func() *entry.Entry); ok { 131 | r0 = rf() 132 | } else { 133 | if ret.Get(0) != nil { 134 | r0 = ret.Get(0).(*entry.Entry) 135 | } 136 | } 137 | 138 | return r0 139 | } 140 | 141 | // NewChangelog creates a new instance of Changelog. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 142 | // The first argument is typically a *testing.T value. 143 | func NewChangelog(t interface { 144 | mock.TestingT 145 | Cleanup(func()) 146 | }) *Changelog { 147 | mock := &Changelog{} 148 | mock.Mock.Test(t) 149 | 150 | t.Cleanup(func() { mock.AssertExpectations(t) }) 151 | 152 | return mock 153 | } 154 | -------------------------------------------------------------------------------- /mocks/GitClient.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.40.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | time "time" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // GitClient is an autogenerated mock type for the GitClient type 12 | type GitClient struct { 13 | mock.Mock 14 | } 15 | 16 | // GetDateOfHash provides a mock function with given fields: hash 17 | func (_m *GitClient) GetDateOfHash(hash string) (time.Time, error) { 18 | ret := _m.Called(hash) 19 | 20 | if len(ret) == 0 { 21 | panic("no return value specified for GetDateOfHash") 22 | } 23 | 24 | var r0 time.Time 25 | var r1 error 26 | if rf, ok := ret.Get(0).(func(string) (time.Time, error)); ok { 27 | return rf(hash) 28 | } 29 | if rf, ok := ret.Get(0).(func(string) time.Time); ok { 30 | r0 = rf(hash) 31 | } else { 32 | r0 = ret.Get(0).(time.Time) 33 | } 34 | 35 | if rf, ok := ret.Get(1).(func(string) error); ok { 36 | r1 = rf(hash) 37 | } else { 38 | r1 = ret.Error(1) 39 | } 40 | 41 | return r0, r1 42 | } 43 | 44 | // GetFirstCommit provides a mock function with given fields: 45 | func (_m *GitClient) GetFirstCommit() (string, error) { 46 | ret := _m.Called() 47 | 48 | if len(ret) == 0 { 49 | panic("no return value specified for GetFirstCommit") 50 | } 51 | 52 | var r0 string 53 | var r1 error 54 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 55 | return rf() 56 | } 57 | if rf, ok := ret.Get(0).(func() string); ok { 58 | r0 = rf() 59 | } else { 60 | r0 = ret.Get(0).(string) 61 | } 62 | 63 | if rf, ok := ret.Get(1).(func() error); ok { 64 | r1 = rf() 65 | } else { 66 | r1 = ret.Error(1) 67 | } 68 | 69 | return r0, r1 70 | } 71 | 72 | // GetLastCommit provides a mock function with given fields: 73 | func (_m *GitClient) GetLastCommit() (string, error) { 74 | ret := _m.Called() 75 | 76 | if len(ret) == 0 { 77 | panic("no return value specified for GetLastCommit") 78 | } 79 | 80 | var r0 string 81 | var r1 error 82 | if rf, ok := ret.Get(0).(func() (string, error)); ok { 83 | return rf() 84 | } 85 | if rf, ok := ret.Get(0).(func() string); ok { 86 | r0 = rf() 87 | } else { 88 | r0 = ret.Get(0).(string) 89 | } 90 | 91 | if rf, ok := ret.Get(1).(func() error); ok { 92 | r1 = rf() 93 | } else { 94 | r1 = ret.Error(1) 95 | } 96 | 97 | return r0, r1 98 | } 99 | 100 | // NewGitClient creates a new instance of GitClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 101 | // The first argument is typically a *testing.T value. 102 | func NewGitClient(t interface { 103 | mock.TestingT 104 | Cleanup(func()) 105 | }) *GitClient { 106 | mock := &GitClient{} 107 | mock.Mock.Test(t) 108 | 109 | t.Cleanup(func() { mock.AssertExpectations(t) }) 110 | 111 | return mock 112 | } 113 | -------------------------------------------------------------------------------- /mocks/GitHubClient.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.40.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | githubclient "github.com/chelnak/gh-changelog/internal/githubclient" 7 | mock "github.com/stretchr/testify/mock" 8 | 9 | time "time" 10 | ) 11 | 12 | // GitHubClient is an autogenerated mock type for the GitHubClient type 13 | type GitHubClient struct { 14 | mock.Mock 15 | } 16 | 17 | // GetPullRequestsBetweenDates provides a mock function with given fields: from, to 18 | func (_m *GitHubClient) GetPullRequestsBetweenDates(from time.Time, to time.Time) ([]githubclient.PullRequest, error) { 19 | ret := _m.Called(from, to) 20 | 21 | if len(ret) == 0 { 22 | panic("no return value specified for GetPullRequestsBetweenDates") 23 | } 24 | 25 | var r0 []githubclient.PullRequest 26 | var r1 error 27 | if rf, ok := ret.Get(0).(func(time.Time, time.Time) ([]githubclient.PullRequest, error)); ok { 28 | return rf(from, to) 29 | } 30 | if rf, ok := ret.Get(0).(func(time.Time, time.Time) []githubclient.PullRequest); ok { 31 | r0 = rf(from, to) 32 | } else { 33 | if ret.Get(0) != nil { 34 | r0 = ret.Get(0).([]githubclient.PullRequest) 35 | } 36 | } 37 | 38 | if rf, ok := ret.Get(1).(func(time.Time, time.Time) error); ok { 39 | r1 = rf(from, to) 40 | } else { 41 | r1 = ret.Error(1) 42 | } 43 | 44 | return r0, r1 45 | } 46 | 47 | // GetRepoName provides a mock function with given fields: 48 | func (_m *GitHubClient) GetRepoName() string { 49 | ret := _m.Called() 50 | 51 | if len(ret) == 0 { 52 | panic("no return value specified for GetRepoName") 53 | } 54 | 55 | var r0 string 56 | if rf, ok := ret.Get(0).(func() string); ok { 57 | r0 = rf() 58 | } else { 59 | r0 = ret.Get(0).(string) 60 | } 61 | 62 | return r0 63 | } 64 | 65 | // GetRepoOwner provides a mock function with given fields: 66 | func (_m *GitHubClient) GetRepoOwner() string { 67 | ret := _m.Called() 68 | 69 | if len(ret) == 0 { 70 | panic("no return value specified for GetRepoOwner") 71 | } 72 | 73 | var r0 string 74 | if rf, ok := ret.Get(0).(func() string); ok { 75 | r0 = rf() 76 | } else { 77 | r0 = ret.Get(0).(string) 78 | } 79 | 80 | return r0 81 | } 82 | 83 | // GetTags provides a mock function with given fields: 84 | func (_m *GitHubClient) GetTags() ([]githubclient.Tag, error) { 85 | ret := _m.Called() 86 | 87 | if len(ret) == 0 { 88 | panic("no return value specified for GetTags") 89 | } 90 | 91 | var r0 []githubclient.Tag 92 | var r1 error 93 | if rf, ok := ret.Get(0).(func() ([]githubclient.Tag, error)); ok { 94 | return rf() 95 | } 96 | if rf, ok := ret.Get(0).(func() []githubclient.Tag); ok { 97 | r0 = rf() 98 | } else { 99 | if ret.Get(0) != nil { 100 | r0 = ret.Get(0).([]githubclient.Tag) 101 | } 102 | } 103 | 104 | if rf, ok := ret.Get(1).(func() error); ok { 105 | r1 = rf() 106 | } else { 107 | r1 = ret.Error(1) 108 | } 109 | 110 | return r0, r1 111 | } 112 | 113 | // NewGitHubClient creates a new instance of GitHubClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 114 | // The first argument is typically a *testing.T value. 115 | func NewGitHubClient(t interface { 116 | mock.TestingT 117 | Cleanup(func()) 118 | }) *GitHubClient { 119 | mock := &GitHubClient{} 120 | mock.Mock.Test(t) 121 | 122 | t.Cleanup(func() { mock.AssertExpectations(t) }) 123 | 124 | return mock 125 | } 126 | -------------------------------------------------------------------------------- /mocks/Logger.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.40.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | logging "github.com/chelnak/gh-changelog/internal/logging" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Logger is an autogenerated mock type for the Logger type 11 | type Logger struct { 12 | mock.Mock 13 | } 14 | 15 | // Complete provides a mock function with given fields: 16 | func (_m *Logger) Complete() { 17 | _m.Called() 18 | } 19 | 20 | // Errorf provides a mock function with given fields: format, args 21 | func (_m *Logger) Errorf(format string, args ...interface{}) { 22 | var _ca []interface{} 23 | _ca = append(_ca, format) 24 | _ca = append(_ca, args...) 25 | _m.Called(_ca...) 26 | } 27 | 28 | // GetType provides a mock function with given fields: 29 | func (_m *Logger) GetType() logging.LoggerType { 30 | ret := _m.Called() 31 | 32 | if len(ret) == 0 { 33 | panic("no return value specified for GetType") 34 | } 35 | 36 | var r0 logging.LoggerType 37 | if rf, ok := ret.Get(0).(func() logging.LoggerType); ok { 38 | r0 = rf() 39 | } else { 40 | r0 = ret.Get(0).(logging.LoggerType) 41 | } 42 | 43 | return r0 44 | } 45 | 46 | // Infof provides a mock function with given fields: format, args 47 | func (_m *Logger) Infof(format string, args ...interface{}) { 48 | var _ca []interface{} 49 | _ca = append(_ca, format) 50 | _ca = append(_ca, args...) 51 | _m.Called(_ca...) 52 | } 53 | 54 | // NewLogger creates a new instance of Logger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 55 | // The first argument is typically a *testing.T value. 56 | func NewLogger(t interface { 57 | mock.TestingT 58 | Cleanup(func()) 59 | }) *Logger { 60 | mock := &Logger{} 61 | mock.Mock.Test(t) 62 | 63 | t.Cleanup(func() { mock.AssertExpectations(t) }) 64 | 65 | return mock 66 | } 67 | -------------------------------------------------------------------------------- /mocks/Parser.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.40.1. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | changelog "github.com/chelnak/gh-changelog/pkg/changelog" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Parser is an autogenerated mock type for the Parser type 11 | type Parser struct { 12 | mock.Mock 13 | } 14 | 15 | // Parse provides a mock function with given fields: 16 | func (_m *Parser) Parse() (changelog.Changelog, error) { 17 | ret := _m.Called() 18 | 19 | if len(ret) == 0 { 20 | panic("no return value specified for Parse") 21 | } 22 | 23 | var r0 changelog.Changelog 24 | var r1 error 25 | if rf, ok := ret.Get(0).(func() (changelog.Changelog, error)); ok { 26 | return rf() 27 | } 28 | if rf, ok := ret.Get(0).(func() changelog.Changelog); ok { 29 | r0 = rf() 30 | } else { 31 | if ret.Get(0) != nil { 32 | r0 = ret.Get(0).(changelog.Changelog) 33 | } 34 | } 35 | 36 | if rf, ok := ret.Get(1).(func() error); ok { 37 | r1 = rf() 38 | } else { 39 | r1 = ret.Error(1) 40 | } 41 | 42 | return r0, r1 43 | } 44 | 45 | // NewParser creates a new instance of Parser. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 46 | // The first argument is typically a *testing.T value. 47 | func NewParser(t interface { 48 | mock.TestingT 49 | Cleanup(func()) 50 | }) *Parser { 51 | mock := &Parser{} 52 | mock.Mock.Test(t) 53 | 54 | t.Cleanup(func() { mock.AssertExpectations(t) }) 55 | 56 | return mock 57 | } 58 | -------------------------------------------------------------------------------- /pkg/builder/builder.go: -------------------------------------------------------------------------------- 1 | // Package builder is responsible for building the changelog. 2 | package builder 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "os/exec" 8 | "strings" 9 | "time" 10 | 11 | "github.com/chelnak/gh-changelog/internal/configuration" 12 | "github.com/chelnak/gh-changelog/internal/gitclient" 13 | "github.com/chelnak/gh-changelog/internal/githubclient" 14 | "github.com/chelnak/gh-changelog/internal/logging" 15 | "github.com/chelnak/gh-changelog/internal/utils" 16 | "github.com/chelnak/gh-changelog/pkg/changelog" 17 | "github.com/chelnak/gh-changelog/pkg/entry" 18 | ) 19 | 20 | var Now = time.Now // must be a better way to stub this 21 | 22 | type BuilderOptions struct { 23 | Logger string 24 | NextVersion string 25 | FromVersion string 26 | LatestVersion bool 27 | GitClient gitclient.GitClient 28 | GitHubClient githubclient.GitHubClient 29 | } 30 | 31 | func (bo *BuilderOptions) setupGitClient() { 32 | if bo.GitClient == nil { 33 | bo.GitClient = gitclient.NewGitClient(exec.Command) 34 | } 35 | } 36 | 37 | func (bo *BuilderOptions) setupGitHubClient() error { 38 | if bo.GitHubClient == nil { 39 | client, err := githubclient.NewGitHubClient() 40 | if err != nil { 41 | return err 42 | } 43 | bo.GitHubClient = client 44 | } 45 | 46 | return nil 47 | } 48 | 49 | type Builder interface { 50 | BuildChangelog() (changelog.Changelog, error) 51 | } 52 | 53 | type builder struct { 54 | nextVersion string 55 | fromVersion string 56 | latestVersion bool 57 | tags []githubclient.Tag 58 | changelog changelog.Changelog 59 | git gitclient.GitClient 60 | github githubclient.GitHubClient 61 | logger logging.Logger 62 | } 63 | 64 | func NewBuilder(options BuilderOptions) (Builder, error) { 65 | options.setupGitClient() 66 | 67 | if err := options.setupGitHubClient(); err != nil { 68 | return nil, err 69 | } 70 | 71 | changelog := changelog.NewChangelog( 72 | options.GitHubClient.GetRepoOwner(), 73 | options.GitHubClient.GetRepoName(), 74 | ) 75 | 76 | builder := &builder{ 77 | nextVersion: options.NextVersion, 78 | fromVersion: options.FromVersion, 79 | latestVersion: options.LatestVersion, 80 | changelog: changelog, 81 | git: options.GitClient, 82 | github: options.GitHubClient, 83 | } 84 | 85 | loggerType, err := logging.GetLoggerType(options.Logger) 86 | if err != nil { 87 | return builder, err 88 | } 89 | 90 | builder.logger = logging.NewLogger(loggerType) 91 | 92 | return builder, nil 93 | } 94 | 95 | func (b *builder) BuildChangelog() (changelog.Changelog, error) { 96 | // defer b.spinnerManager.Stop() 97 | 98 | b.logger.Infof("Fetching tags...") 99 | err := b.updateTags() 100 | if err != nil { 101 | b.logger.Errorf(err.Error()) 102 | return nil, err 103 | } 104 | 105 | if b.nextVersion != "" { 106 | err = b.setNextVersion() 107 | if err != nil { 108 | b.logger.Errorf(err.Error()) 109 | return nil, err 110 | } 111 | } 112 | 113 | if configuration.Config.ShowUnreleased && b.nextVersion == "" { 114 | b.logger.Infof("Getting unreleased entries") 115 | err := b.getUnreleasedEntries() 116 | if err != nil { 117 | return nil, err 118 | } 119 | } 120 | 121 | for i := 0; i < len(b.tags); i++ { 122 | var previousTag githubclient.Tag 123 | if i+1 == len(b.tags) { 124 | previousTag = githubclient.Tag{} 125 | } else { 126 | previousTag = b.tags[i+1] 127 | } 128 | 129 | err := b.getReleasedEntries(previousTag, b.tags[i]) 130 | if err != nil { 131 | return nil, fmt.Errorf("could not process pull requests: %v", err) 132 | } 133 | 134 | if strings.EqualFold(b.fromVersion, b.tags[i].Name) || b.latestVersion { 135 | break 136 | } 137 | } 138 | 139 | b.logger.Infof("Open %s or run 'gh changelog show' to view your changelog.", configuration.Config.FileName) 140 | b.logger.Complete() 141 | 142 | return b.changelog, nil 143 | } 144 | 145 | func (b *builder) updateTags() error { 146 | tags, err := b.github.GetTags() 147 | if err != nil { 148 | return err 149 | } 150 | 151 | if len(tags) == 0 && b.nextVersion == "" { 152 | return errors.New("there are no tags on this repository to evaluate and the --next-version flag was not provided") 153 | } 154 | 155 | b.tags = append(b.tags, tags...) 156 | 157 | return nil 158 | } 159 | 160 | func (b *builder) setNextVersion() error { 161 | if !utils.IsValidSemanticVersion(b.nextVersion) { 162 | return fmt.Errorf("'%s' is not a valid semantic version", b.nextVersion) 163 | } 164 | if len(b.tags) > 0 { 165 | currentVersion := b.tags[0].Name 166 | if !utils.NextVersionIsGreaterThanCurrent(b.nextVersion, currentVersion) { 167 | return fmt.Errorf("the next version should be greater than the former: '%s' ≤ '%s'", b.nextVersion, currentVersion) 168 | } 169 | } 170 | 171 | lastCommitSha, err := b.git.GetLastCommit() 172 | if err != nil { 173 | return err 174 | } 175 | 176 | tag := githubclient.Tag{ 177 | Name: b.nextVersion, 178 | Sha: lastCommitSha, 179 | Date: Now(), 180 | } 181 | 182 | b.tags = append([]githubclient.Tag{tag}, b.tags...) 183 | 184 | return nil 185 | } 186 | 187 | func (b *builder) getUnreleasedEntries() error { 188 | pullRequests, err := b.github.GetPullRequestsBetweenDates(b.tags[0].Date, Now()) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | unreleased := []string{} 194 | for _, pr := range pullRequests { 195 | if !hasExcludedLabel(pr) { 196 | line := b.formatEntryLine(pr) 197 | unreleased = append(unreleased, line) 198 | } 199 | } 200 | 201 | b.changelog.AddUnreleased(unreleased) 202 | 203 | return nil 204 | } 205 | 206 | func (b *builder) getReleasedEntries(previousTag, currentTag githubclient.Tag) error { 207 | b.logger.Infof("Processing tag: 🏷️ %s", currentTag.Name) 208 | 209 | pullRequests, err := b.github.GetPullRequestsBetweenDates(previousTag.Date, currentTag.Date) 210 | if err != nil { 211 | return err 212 | } 213 | 214 | e := entry.NewEntry(currentTag.Name, currentTag.Date) 215 | 216 | for _, pr := range pullRequests { 217 | if !hasExcludedLabel(pr) { 218 | section := getSection(pr.Labels) 219 | line := b.formatEntryLine(pr) 220 | 221 | if section != "" { 222 | err := e.Append(section, line) 223 | if err != nil { 224 | return err 225 | } 226 | } 227 | } 228 | } 229 | b.changelog.Insert(e) 230 | return nil 231 | } 232 | 233 | func (b *builder) formatEntryLine(pr githubclient.PullRequest) string { 234 | return fmt.Sprintf( 235 | "%s [#%d](https://github.com/%s/%s/pull/%d) ([%s](https://github.com/%s))", 236 | pr.Title, 237 | pr.Number, 238 | b.github.GetRepoOwner(), 239 | b.github.GetRepoName(), 240 | pr.Number, 241 | pr.User, 242 | pr.User, 243 | ) 244 | } 245 | 246 | func hasExcludedLabel(pr githubclient.PullRequest) bool { 247 | excludedLabels := configuration.Config.ExcludedLabels 248 | for _, label := range pr.Labels { 249 | if utils.SliceContainsString(excludedLabels, label.Name) { 250 | return true 251 | } 252 | } 253 | 254 | return false 255 | } 256 | 257 | func getSection(labels []githubclient.PullRequestLabel) string { 258 | sections := configuration.Config.Sections 259 | 260 | lookup := make(map[string]string) 261 | for k, v := range sections { 262 | for _, label := range v { 263 | lookup[label] = k 264 | } 265 | } 266 | 267 | var section string 268 | skipUnlabelledEntries := configuration.Config.SkipEntriesWithoutLabel 269 | 270 | if !skipUnlabelledEntries { 271 | section = "Other" 272 | } 273 | 274 | for _, label := range labels { 275 | if _, ok := lookup[label.Name]; ok { 276 | section = lookup[label.Name] 277 | } 278 | } 279 | 280 | return section 281 | } 282 | -------------------------------------------------------------------------------- /pkg/builder/builder_test.go: -------------------------------------------------------------------------------- 1 | package builder_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/chelnak/gh-changelog/internal/configuration" 8 | "github.com/chelnak/gh-changelog/internal/githubclient" 9 | "github.com/chelnak/gh-changelog/mocks" 10 | "github.com/chelnak/gh-changelog/pkg/builder" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | const ( 15 | repoName = "repo-name" 16 | repoOwner = "repo-owner" 17 | ) 18 | 19 | func safeParseTime() time.Time { 20 | time, _ := time.Parse(time.RFC3339, time.Time{}.String()) 21 | return time 22 | } 23 | 24 | func setupMockGitClient() *mocks.GitClient { 25 | mockGitClient := &mocks.GitClient{} 26 | mockGitClient.On("GetFirstCommit").Return("42d4c93b23eaf307c5f9712f4c62014fe38332bd", nil) 27 | mockGitClient.On("GetLastCommit").Return("0d724ba5b4235aa88d45a20f4ecd8db4b4695cf1", nil) 28 | mockGitClient.On("GetDateOfHash", "42d4c93b23eaf307c5f9712f4c62014fe38332bd").Return(safeParseTime(), nil).Once() 29 | return mockGitClient 30 | } 31 | 32 | func setupMockGitHubClient() *mocks.GitHubClient { 33 | mockGitHubClient := &mocks.GitHubClient{} 34 | mockGitHubClient.On("GetTags").Return([]githubclient.Tag{ 35 | { 36 | Name: "v2.0.0", 37 | Sha: "0d724ba5b4235aa88d45a20f4ecd8db4b4695cf1", 38 | Date: safeParseTime(), 39 | }, 40 | { 41 | Name: "v1.0.0", 42 | Sha: "42d4c93b23eaf307c5f9712f4c62014fe38332bd", 43 | Date: safeParseTime(), 44 | }, 45 | }, nil) 46 | 47 | // bad ?? 48 | builder.Now = func() time.Time { 49 | return safeParseTime() 50 | } 51 | 52 | mockGitHubClient.On("GetPullRequestsBetweenDates", safeParseTime(), builder.Now()).Return([]githubclient.PullRequest{}, nil).Once() 53 | mockGitHubClient.On("GetPullRequestsBetweenDates", time.Time{}, time.Time{}).Return([]githubclient.PullRequest{ 54 | { 55 | Number: 2, 56 | Title: "this is a test pr 2", 57 | User: "test-user", 58 | Labels: []githubclient.PullRequestLabel{ 59 | { 60 | Name: "enhancement", 61 | }, 62 | }, 63 | }, 64 | { 65 | Number: 1, 66 | Title: "this is a test pr", 67 | User: "test-user", 68 | Labels: []githubclient.PullRequestLabel{ 69 | { 70 | Name: "enhancement", 71 | }, 72 | }, 73 | }, 74 | }, nil) 75 | 76 | mockGitHubClient.On("GetRepoName").Return(repoName) 77 | mockGitHubClient.On("GetRepoOwner").Return(repoOwner) 78 | 79 | return mockGitHubClient 80 | } 81 | 82 | func setupBuilder(opts *builder.BuilderOptions) builder.Builder { // nolint:unparam 83 | _ = configuration.InitConfig() 84 | 85 | if opts == nil { 86 | opts = &builder.BuilderOptions{} 87 | } 88 | 89 | if opts.GitClient == nil { 90 | opts.GitClient = setupMockGitClient() 91 | } 92 | 93 | if opts.GitHubClient == nil { 94 | opts.GitHubClient = setupMockGitHubClient() 95 | } 96 | 97 | b, _ := builder.NewBuilder(*opts) 98 | 99 | return b 100 | } 101 | 102 | func TestChangelogBuilder(t *testing.T) { 103 | builder := setupBuilder(nil) 104 | 105 | changelog, err := builder.BuildChangelog() 106 | assert.NoError(t, err) 107 | 108 | assert.Equal(t, repoName, changelog.GetRepoName()) 109 | assert.Equal(t, repoOwner, changelog.GetRepoOwner()) 110 | 111 | assert.Len(t, changelog.GetUnreleased(), 0) 112 | assert.Len(t, changelog.GetEntries(), 2) 113 | 114 | assert.Equal( 115 | t, 116 | "this is a test pr 2 [#2](https://github.com/repo-owner/repo-name/pull/2) ([test-user](https://github.com/test-user))", 117 | changelog.GetEntries()[0].Added[0], 118 | ) 119 | } 120 | 121 | func TestShouldErrorWithAnOlderNextVersion(t *testing.T) { 122 | opts := &builder.BuilderOptions{ 123 | NextVersion: "v0.0.1", 124 | } 125 | builder := setupBuilder(opts) 126 | _, err := builder.BuildChangelog() 127 | 128 | assert.Error(t, err) 129 | assert.Equal(t, "the next version should be greater than the former: 'v0.0.1' ≤ 'v2.0.0'", err.Error()) 130 | } 131 | 132 | func TestShouldErrorWithNoTags(t *testing.T) { 133 | mockGitHubClient := &mocks.GitHubClient{} 134 | mockGitHubClient.On("GetTags").Return([]githubclient.Tag{}, nil) 135 | mockGitHubClient.On("GetRepoName").Return(repoName) 136 | mockGitHubClient.On("GetRepoOwner").Return(repoOwner) 137 | 138 | opts := &builder.BuilderOptions{ 139 | GitHubClient: mockGitHubClient, 140 | } 141 | 142 | builder := setupBuilder(opts) 143 | _, err := builder.BuildChangelog() 144 | 145 | assert.Error(t, err) 146 | assert.Equal(t, "there are no tags on this repository to evaluate and the --next-version flag was not provided", err.Error()) 147 | } 148 | 149 | func TestWithFromVersion(t *testing.T) { 150 | opts := &builder.BuilderOptions{ 151 | FromVersion: "v2.0.0", 152 | } 153 | 154 | builder := setupBuilder(opts) 155 | changelog, err := builder.BuildChangelog() 156 | 157 | assert.NoError(t, err) 158 | assert.Len(t, changelog.GetEntries(), 1) 159 | assert.Equal( 160 | t, 161 | "this is a test pr 2 [#2](https://github.com/repo-owner/repo-name/pull/2) ([test-user](https://github.com/test-user))", 162 | changelog.GetEntries()[0].Added[0], 163 | ) 164 | } 165 | 166 | func TestWithFromLastVersion(t *testing.T) { 167 | opts := &builder.BuilderOptions{ 168 | LatestVersion: true, 169 | } 170 | 171 | builder := setupBuilder(opts) 172 | changelog, err := builder.BuildChangelog() 173 | 174 | assert.NoError(t, err) 175 | assert.Len(t, changelog.GetEntries(), 1) 176 | assert.Equal( 177 | t, 178 | "this is a test pr 2 [#2](https://github.com/repo-owner/repo-name/pull/2) ([test-user](https://github.com/test-user))", 179 | changelog.GetEntries()[0].Added[0], 180 | ) 181 | } 182 | -------------------------------------------------------------------------------- /pkg/changelog/changelog.go: -------------------------------------------------------------------------------- 1 | // Package changelog provides the datastructure that is responsible for 2 | // holding the changelog data. 3 | package changelog 4 | 5 | import ( 6 | "github.com/chelnak/gh-changelog/pkg/entry" 7 | ) 8 | 9 | // Changelog is an interface for a changelog datastructure. 10 | type Changelog interface { 11 | GetRepoName() string 12 | GetRepoOwner() string 13 | GetUnreleased() []string 14 | AddUnreleased([]string) 15 | Insert(entry.Entry) 16 | GetEntries() []*entry.Entry 17 | Head() *entry.Entry 18 | Tail() *entry.Entry 19 | } 20 | 21 | type changelog struct { 22 | // Support for linked list structure 23 | head *entry.Entry 24 | tail *entry.Entry 25 | 26 | repoName string 27 | repoOwner string 28 | unreleased []string 29 | } 30 | 31 | // GetRepoName returns the name of the repository. 32 | func (c *changelog) GetRepoName() string { 33 | return c.repoName 34 | } 35 | 36 | // GetRepoOwner returns the owner of the repository. 37 | func (c *changelog) GetRepoOwner() string { 38 | return c.repoOwner 39 | } 40 | 41 | // GetUnreleased returns the unreleased changes if any exist. 42 | func (c *changelog) GetUnreleased() []string { 43 | return c.unreleased 44 | } 45 | 46 | // AddUnreleased adds a list of unreleased changes to the changelog. 47 | // This only needs to be a slice of strings. 48 | func (c *changelog) AddUnreleased(entry []string) { 49 | c.unreleased = append(c.unreleased, entry...) 50 | } 51 | 52 | // Insert inserts a new entry into the changelog. 53 | func (c *changelog) Insert(e entry.Entry) { 54 | if c.head != nil { 55 | e.Next = c.head 56 | c.head.Previous = &e 57 | } 58 | c.head = &e 59 | 60 | currentEntry := c.head 61 | for currentEntry.Next != nil { 62 | currentEntry = currentEntry.Next 63 | } 64 | c.tail = currentEntry 65 | } 66 | 67 | // GetEntries returns a list of entries in the changelog. 68 | // This is a convenience method that creates a contiguous list of entries 69 | // that can be iterated over. The latest entry will be the first item in the list.. 70 | func (c *changelog) GetEntries() []*entry.Entry { 71 | entries := []*entry.Entry{} 72 | currentEntry := c.tail 73 | for currentEntry != nil { 74 | entries = append(entries, currentEntry) 75 | currentEntry = currentEntry.Previous 76 | } 77 | 78 | return entries 79 | } 80 | 81 | // Head returns the first entry in the changelog. 82 | func (c *changelog) Head() *entry.Entry { 83 | return c.head 84 | } 85 | 86 | // Tail returns the last entry in the changelog. 87 | func (c *changelog) Tail() *entry.Entry { 88 | return c.tail 89 | } 90 | 91 | // NewChangelog creates a new changelog datastructure. 92 | func NewChangelog(repoOwner string, repoName string) Changelog { 93 | return &changelog{ 94 | repoName: repoName, 95 | repoOwner: repoOwner, 96 | unreleased: []string{}, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/changelog/changelog_test.go: -------------------------------------------------------------------------------- 1 | package changelog_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/chelnak/gh-changelog/pkg/changelog" 8 | "github.com/chelnak/gh-changelog/pkg/entry" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const ( 13 | repoName = "repo-name" 14 | repoOwner = "repo-owner" 15 | ) 16 | 17 | var entries = []entry.Entry{ 18 | { 19 | Tag: "v2.0.0", 20 | Date: time.Time{}, 21 | }, 22 | { 23 | Tag: "v1.0.0", 24 | Date: time.Time{}, 25 | }, 26 | } 27 | 28 | func TetstNewChangelog(t *testing.T) { 29 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 30 | assert.Equal(t, repoName, testChangelog.GetRepoName()) 31 | assert.Equal(t, repoOwner, testChangelog.GetRepoOwner()) 32 | assert.Equal(t, 0, len(testChangelog.GetEntries())) 33 | assert.Equal(t, 0, len(testChangelog.GetUnreleased())) 34 | } 35 | 36 | func TestInsert(t *testing.T) { 37 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 38 | for _, e := range entries { 39 | err := e.Append("added", "test") 40 | assert.Nil(t, err) 41 | 42 | testChangelog.Insert(e) 43 | } 44 | 45 | entries := testChangelog.GetEntries() 46 | assert.Equal(t, 2, len(entries)) 47 | assert.Equal(t, 1, len(entries[0].Added)) 48 | assert.Equal(t, "test", entries[0].Added[0]) 49 | } 50 | 51 | func TestTail(t *testing.T) { 52 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 53 | 54 | for _, e := range entries { 55 | err := e.Append("added", "test") 56 | assert.Nil(t, err) 57 | 58 | testChangelog.Insert(e) 59 | } 60 | 61 | tail := testChangelog.Tail() 62 | assert.Equal(t, "v2.0.0", tail.Tag) 63 | } 64 | 65 | func TestHead(t *testing.T) { 66 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 67 | entries := []entry.Entry{ 68 | { 69 | Tag: "v2.0.0", 70 | Date: time.Time{}, 71 | }, 72 | { 73 | Tag: "v1.0.0", 74 | Date: time.Time{}, 75 | }, 76 | } 77 | 78 | for _, e := range entries { 79 | err := e.Append("added", "test") 80 | assert.Nil(t, err) 81 | 82 | testChangelog.Insert(e) 83 | } 84 | 85 | head := testChangelog.Head() 86 | assert.Equal(t, "v1.0.0", head.Tag) 87 | } 88 | 89 | func TestAddUnreleased(t *testing.T) { 90 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 91 | testChangelog.AddUnreleased([]string{"test"}) 92 | 93 | unreleased := testChangelog.GetUnreleased() 94 | 95 | assert.Equal(t, 1, len(unreleased)) 96 | assert.Equal(t, "test", unreleased[0]) 97 | } 98 | 99 | func TestGetEntries(t *testing.T) { 100 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 101 | for _, e := range entries { 102 | err := e.Append("added", "test") 103 | assert.Nil(t, err) 104 | 105 | testChangelog.Insert(e) 106 | } 107 | 108 | assert.Equal(t, 2, len(testChangelog.GetEntries())) 109 | assert.Equal(t, "v2.0.0", testChangelog.GetEntries()[0].Tag) 110 | } 111 | -------------------------------------------------------------------------------- /pkg/entry/entry.go: -------------------------------------------------------------------------------- 1 | // Package entry provides a datastructure for changelog entries. 2 | package entry 3 | 4 | import ( 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "time" 9 | 10 | "golang.org/x/text/cases" 11 | "golang.org/x/text/language" 12 | ) 13 | 14 | // Entry represents a single entry in the changelog 15 | type Entry struct { 16 | Previous *Entry // Get or Set the previous entry in the changelog. 17 | Next *Entry // Get or Set the next entry in the changelog. 18 | 19 | Tag string 20 | PrevTag string 21 | Date time.Time 22 | Added []string 23 | Changed []string 24 | Deprecated []string 25 | Removed []string 26 | Fixed []string 27 | Security []string 28 | Other []string 29 | } 30 | 31 | // Append updates the given section in the entry.. 32 | func (e *Entry) Append(section string, entry string) error { 33 | switch strings.ToLower(section) { 34 | case "added": 35 | e.Added = append(e.Added, entry) 36 | case "changed": 37 | e.Changed = append(e.Changed, entry) 38 | case "deprecated": 39 | e.Deprecated = append(e.Deprecated, entry) 40 | case "removed": 41 | e.Removed = append(e.Removed, entry) 42 | case "fixed": 43 | e.Fixed = append(e.Fixed, entry) 44 | case "security": 45 | e.Security = append(e.Security, entry) 46 | case "other": 47 | e.Other = append(e.Other, entry) 48 | default: 49 | return fmt.Errorf("unknown entry type '%s'", section) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // GetSection uses reflection to return a given section in the entry. 56 | // If the section does not exist, an empty slice is returned. 57 | func (e *Entry) GetSection(section string) []string { 58 | title := cases.Title(language.English) 59 | ref := reflect.ValueOf(e).Elem().FieldByName(title.String(section)) 60 | if ref.IsValid() { 61 | return ref.Interface().([]string) 62 | } 63 | return nil 64 | } 65 | 66 | // NewEntry creates a new entry (node) that can be added to the changelog datastructure. 67 | func NewEntry(tag string, date time.Time) Entry { 68 | return Entry{ 69 | Tag: tag, 70 | Date: date, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/entry/entry_test.go: -------------------------------------------------------------------------------- 1 | package entry_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/chelnak/gh-changelog/pkg/changelog" 9 | "github.com/chelnak/gh-changelog/pkg/entry" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | const ( 14 | repoName = "gh-changelog" 15 | repoOwner = "chelnak" 16 | ) 17 | 18 | func TestNewEntry(t *testing.T) { 19 | entry := entry.NewEntry("v2.0.0", time.Time{}) 20 | 21 | assert.Equal(t, "v2.0.0", entry.Tag) 22 | assert.Equal(t, time.Time{}, entry.Date) 23 | } 24 | 25 | func TestPrevious(t *testing.T) { 26 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 27 | entries := []entry.Entry{ 28 | { 29 | Tag: "v2.0.0", 30 | Date: time.Time{}, 31 | }, 32 | { 33 | Tag: "v1.0.0", 34 | Date: time.Time{}, 35 | }, 36 | } 37 | 38 | for _, e := range entries { 39 | err := e.Append("added", "test") 40 | assert.Nil(t, err) 41 | 42 | testChangelog.Insert(e) 43 | } 44 | 45 | tail := testChangelog.Tail() 46 | previous := tail.Previous 47 | assert.Equal(t, "v1.0.0", previous.Tag) 48 | } 49 | 50 | func TestNext(t *testing.T) { 51 | var testChangelog = changelog.NewChangelog(repoOwner, repoName) 52 | entries := []entry.Entry{ 53 | { 54 | Tag: "v2.0.0", 55 | Date: time.Time{}, 56 | }, 57 | { 58 | 59 | Tag: "v1.0.0", 60 | Date: time.Time{}, 61 | }, 62 | } 63 | 64 | for _, e := range entries { 65 | err := e.Append("added", "test") 66 | assert.Nil(t, err) 67 | 68 | testChangelog.Insert(e) 69 | } 70 | 71 | head := testChangelog.Head() 72 | next := head.Next 73 | assert.Equal(t, "v2.0.0", next.Tag) 74 | } 75 | 76 | func TestAppend(t *testing.T) { 77 | tests := []struct { 78 | name string 79 | }{ 80 | { 81 | name: "added", 82 | }, 83 | { 84 | name: "changed", 85 | }, 86 | { 87 | name: "deprecated", 88 | }, 89 | { 90 | name: "removed", 91 | }, 92 | { 93 | name: "fixed", 94 | }, 95 | { 96 | name: "security", 97 | }, 98 | { 99 | name: "other", 100 | }, 101 | } 102 | 103 | e := entry.NewEntry("v2.0.0", time.Time{}) 104 | for _, test := range tests { 105 | t.Run(fmt.Sprintf("Appends a line to section: %s", test.name), func(t *testing.T) { 106 | err := e.Append(test.name, fmt.Sprintf("test %s", test.name)) 107 | assert.Nil(t, err) 108 | 109 | section := e.GetSection(test.name) 110 | assert.Equal(t, 1, len(section)) 111 | assert.Regexp(t, fmt.Sprintf("test %s", test.name), section[0]) 112 | }) 113 | } 114 | } 115 | 116 | func TestReturnsAnErrorWhenAppendingToAnInvalidSection(t *testing.T) { 117 | e := entry.NewEntry("v2.0.0", time.Time{}) 118 | err := e.Append("invalid", "test") 119 | assert.NotNil(t, err) 120 | } 121 | 122 | func TestGetSection(t *testing.T) { 123 | e := entry.NewEntry("v2.0.0", time.Time{}) 124 | err := e.Append("added", "test") 125 | assert.Nil(t, err) 126 | 127 | section := e.GetSection("added") 128 | assert.Equal(t, 1, len(section)) 129 | assert.Equal(t, "test", section[0]) 130 | 131 | section = e.GetSection("invalid") 132 | assert.Equal(t, 0, len(section)) 133 | } 134 | -------------------------------------------------------------------------------- /pkg/parser/parser.go: -------------------------------------------------------------------------------- 1 | // Package parser provides a simple interface for parsing markdown changelogs. 2 | package parser 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/chelnak/gh-changelog/internal/utils" 12 | "github.com/chelnak/gh-changelog/pkg/changelog" 13 | "github.com/chelnak/gh-changelog/pkg/entry" 14 | "github.com/gomarkdown/markdown/ast" 15 | mdparser "github.com/gomarkdown/markdown/parser" 16 | ) 17 | 18 | type parser struct { 19 | path string 20 | repoOwner string 21 | repoName string 22 | } 23 | 24 | // Parser is an interface for parsing markdown changelogs. 25 | type Parser interface { 26 | Parse() (changelog.Changelog, error) 27 | } 28 | 29 | // NewParser returns a new parser for the given changelog.. 30 | func NewParser(path, repoOwner, repoName string) Parser { 31 | return &parser{ 32 | path: path, 33 | repoName: repoName, 34 | repoOwner: repoOwner, 35 | } 36 | } 37 | 38 | // Parse parses the changelog and returns a Changelog struct. 39 | func (p *parser) Parse() (changelog.Changelog, error) { 40 | repoContext, err := utils.GetRepoContext() 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | if p.repoOwner == "" { 46 | p.repoOwner = repoContext.Owner 47 | } 48 | 49 | if p.repoName == "" { 50 | p.repoName = repoContext.Name 51 | } 52 | 53 | data, err := os.ReadFile(filepath.Clean(p.path)) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | markdownParser := mdparser.New() 59 | output := markdownParser.Parse(data) 60 | 61 | var tagIndex []string // This is a list of tags in order 62 | var unreleased []string 63 | var entries = map[string]*entry.Entry{} // Maintain a map of tag to entry 64 | var currentTag string 65 | var currentSection string 66 | 67 | for _, child := range output.GetChildren() { 68 | switch child.(type) { 69 | case *ast.Heading: 70 | if isHeading(child, 2) { 71 | currentTag = getTagFromHeadingLink(child) 72 | if currentTag == "" && isHeadingUnreleased(child) { 73 | currentTag = "Unreleased" 74 | continue 75 | } 76 | date := getDateFromHeading(child) 77 | if _, ok := entries[currentTag]; !ok { 78 | e := entry.NewEntry(currentTag, date) 79 | entries[currentTag] = &e 80 | tagIndex = append(tagIndex, currentTag) 81 | } 82 | } 83 | 84 | if isHeading(child, 3) { 85 | currentSection = getTextFromChildNodes(child) 86 | } 87 | case *ast.List: 88 | items := getItemsFromList(child) 89 | if currentTag == "Unreleased" { 90 | for _, item := range items { 91 | unreleased = append(unreleased, getTextFromChildNodes(item)) 92 | } 93 | continue 94 | } 95 | 96 | for _, item := range items { 97 | err := entries[currentTag].Append(currentSection, getTextFromChildNodes(item)) 98 | if err != nil { 99 | // TODO: Add more context to this error 100 | return nil, fmt.Errorf("error parsing changelog: %s", err) 101 | } 102 | } 103 | default: 104 | // TODO: Add more context to this block 105 | // We are ignoring other types of nodes for now 106 | continue 107 | } 108 | } 109 | 110 | cl := changelog.NewChangelog(p.repoOwner, p.repoName) 111 | 112 | if len(unreleased) > 0 { 113 | cl.AddUnreleased(unreleased) 114 | } 115 | 116 | for _, tag := range tagIndex { 117 | cl.Insert(*entries[tag]) 118 | } 119 | 120 | return cl, nil 121 | } 122 | 123 | func isListItem(node ast.Node) bool { 124 | _, ok := node.(*ast.ListItem) 125 | return ok 126 | } 127 | 128 | func isLink(node ast.Node) bool { 129 | _, ok := node.(*ast.Link) 130 | return ok 131 | } 132 | 133 | func isText(node ast.Node) bool { 134 | _, ok := node.(*ast.Text) 135 | return ok 136 | } 137 | 138 | func isHeading(node ast.Node, level int) bool { 139 | _, heading := node.(*ast.Heading) 140 | ok := heading && node.(*ast.Heading).Level == level 141 | return ok 142 | } 143 | 144 | func isParagraph(node ast.Node) bool { 145 | _, ok := node.(*ast.Paragraph) 146 | return ok 147 | } 148 | 149 | func getItemsFromList(node ast.Node) []*ast.ListItem { 150 | var items []*ast.ListItem 151 | for _, child := range node.GetChildren() { 152 | if isListItem(child) { 153 | items = append(items, child.(*ast.ListItem)) 154 | } 155 | } 156 | return items 157 | } 158 | 159 | func getTextFromChildNodes(node ast.Node) string { 160 | var text []string 161 | for _, child := range node.GetChildren() { 162 | if isParagraph(child) { 163 | text = append(text, getTextFromChildNodes(child)) // This stinks 164 | // text = append(text, "\n") // so does this 165 | } 166 | 167 | if isText(child) { 168 | text = append(text, string(child.(*ast.Text).Literal)) 169 | } 170 | 171 | if isLink(child) { 172 | linkText := getTextFromChildNodes(child) 173 | link := fmt.Sprintf("[%s](%s)", linkText, child.(*ast.Link).Destination) 174 | text = append(text, link) 175 | } 176 | } 177 | return strings.Join(text, "") 178 | } 179 | 180 | func getTagFromHeadingLink(node ast.Node) string { 181 | for _, child := range node.GetChildren() { 182 | if isLink(child) { 183 | return getTextFromChildNodes(child) 184 | } 185 | } 186 | return "" 187 | } 188 | 189 | func getDateFromHeading(node ast.Node) time.Time { 190 | var date time.Time 191 | for _, child := range node.GetChildren() { 192 | if isText(child) { 193 | text := string(child.(*ast.Text).Literal) 194 | if text != "" { 195 | date, err := time.Parse("2006-01-02", strings.ReplaceAll(text, " - ", "")) 196 | if err != nil { 197 | panic(err) 198 | } 199 | return date 200 | } 201 | } 202 | } 203 | return date 204 | } 205 | 206 | func isHeadingUnreleased(node ast.Node) bool { 207 | return strings.Contains(getTextFromChildNodes(node), "Unreleased") 208 | } 209 | -------------------------------------------------------------------------------- /pkg/parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/chelnak/gh-changelog/pkg/parser" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestParser(t *testing.T) { 11 | t.Run("can parse a changelog with an unreleased section", func(t *testing.T) { 12 | p := parser.NewParser("./testdata/unreleased.md", "chelnak", "gh-changelog") 13 | c, err := p.Parse() 14 | require.NoError(t, err) 15 | require.Len(t, c.GetUnreleased(), 2) 16 | require.Len(t, c.GetEntries(), 3) 17 | }) 18 | 19 | t.Run("can parse a changelog without an unreleased section", func(t *testing.T) { 20 | p := parser.NewParser("./testdata/no_unreleased.md", "chelnak", "gh-changelog") 21 | c, err := p.Parse() 22 | require.NoError(t, err) 23 | require.Len(t, c.GetUnreleased(), 0) 24 | require.Len(t, c.GetEntries(), 3) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/parser/testdata/no_unreleased.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). 7 | 8 | ## [v0.15.1](https://github.com/chelnak/gh-changelog/tree/v0.15.1) - 2023-10-09 9 | 10 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.15.0...v0.15.1) 11 | 12 | ### Fixed 13 | 14 | - bugfix: Release creation toggling RepoName & RepoOwner [#142](https://github.com/chelnak/gh-changelog/pull/142) ([Ramesh7](https://github.com/Ramesh7)) 15 | 16 | ## [v0.15.0](https://github.com/chelnak/gh-changelog/tree/v0.15.0) - 2023-10-01 17 | 18 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.14.0...v0.15.0) 19 | 20 | ### Added 21 | 22 | - Improve sections ordering [#139](https://github.com/chelnak/gh-changelog/pull/139) ([smortex](https://github.com/smortex)) 23 | 24 | ## [v0.1.0](https://github.com/chelnak/gh-changelog/tree/v0.1.0) - 2022-04-15 25 | 26 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/42d4c93b23eaf307c5f9712f4c62014fe38332bd...v0.1.0) 27 | -------------------------------------------------------------------------------- /pkg/parser/testdata/unreleased.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). 7 | 8 | ## Unreleased 9 | 10 | - Fix no previous tag when using get cmd [#148](https://github.com/chelnak/gh-changelog/pull/148) ([h0tw1r3](https://github.com/h0tw1r3)) 11 | - Add missing line between "Changed" title and list [#146](https://github.com/chelnak/gh-changelog/pull/146) ([smortex](https://github.com/smortex)) 12 | 13 | ## [v0.15.1](https://github.com/chelnak/gh-changelog/tree/v0.15.1) - 2023-10-09 14 | 15 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.15.0...v0.15.1) 16 | 17 | ### Fixed 18 | 19 | - bugfix: Release creation toggling RepoName & RepoOwner [#142](https://github.com/chelnak/gh-changelog/pull/142) ([Ramesh7](https://github.com/Ramesh7)) 20 | 21 | ## [v0.15.0](https://github.com/chelnak/gh-changelog/tree/v0.15.0) - 2023-10-01 22 | 23 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/v0.14.0...v0.15.0) 24 | 25 | ### Added 26 | 27 | - Improve sections ordering [#139](https://github.com/chelnak/gh-changelog/pull/139) ([smortex](https://github.com/smortex)) 28 | 29 | ## [v0.1.0](https://github.com/chelnak/gh-changelog/tree/v0.1.0) - 2022-04-15 30 | 31 | [Full Changelog](https://github.com/chelnak/gh-changelog/compare/42d4c93b23eaf307c5f9712f4c62014fe38332bd...v0.1.0) 32 | --------------------------------------------------------------------------------